tayo 0.1.13 β†’ 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
4
+ require "tty-prompt"
5
+ require_relative "../proxy/cloudflare_client"
6
+ require_relative "../proxy/docker_manager"
7
+ require_relative "../proxy/network_config"
8
+ require_relative "../proxy/traefik_config"
9
+ require_relative "../proxy/welcome_service"
10
+
11
+ module Tayo
12
+ module Commands
13
+ class Proxy
14
+ def execute
15
+ puts "πŸš€ Kamal Proxy와 Caddy 섀정을 μ‹œμž‘ν•©λ‹ˆλ‹€...".colorize(:green)
16
+ puts ""
17
+
18
+ # 1. Cloudflare μ„€μ •
19
+ cloudflare = Tayo::Proxy::CloudflareClient.new
20
+ cloudflare.ensure_token
21
+
22
+ # 2. λ„€νŠΈμ›Œν¬ μ„€μ •
23
+ network = Tayo::Proxy::NetworkConfig.new
24
+ network.detect_ips
25
+ network.configure_ports
26
+
27
+ # 3. Docker 확인
28
+ docker = Tayo::Proxy::DockerManager.new
29
+ docker.check_containers
30
+
31
+ # 4. 도메인 선택
32
+ selected_domains = cloudflare.select_domains
33
+
34
+ if selected_domains.empty?
35
+ puts "❌ 도메인이 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. μ’…λ£Œν•©λ‹ˆλ‹€.".colorize(:red)
36
+ return
37
+ end
38
+
39
+ # 5. DNS μ„€μ •
40
+ puts "\nπŸ“ DNS λ ˆμ½”λ“œλ₯Ό μ„€μ •ν•©λ‹ˆλ‹€...".colorize(:yellow)
41
+ cloudflare.setup_dns_records(selected_domains, network.public_ip)
42
+
43
+ # 6. Welcome μ„œλΉ„μŠ€ 확인
44
+ welcome = Tayo::Proxy::WelcomeService.new
45
+ welcome.ensure_running
46
+
47
+ # 7. Traefik μ„€μ •
48
+ traefik = Tayo::Proxy::TraefikConfig.new
49
+ traefik.setup(selected_domains)
50
+
51
+ # 8. μ΅œμ’… μ•ˆλ‚΄
52
+ show_summary(selected_domains, network)
53
+
54
+ rescue => e
55
+ puts "❌ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: #{e.message}".colorize(:red)
56
+ puts e.backtrace.join("\n") if ENV["DEBUG"]
57
+ exit 1
58
+ end
59
+
60
+ private
61
+
62
+ def show_summary(domains, network)
63
+ puts "\n" + "="*60
64
+ puts "βœ… Proxy 섀정이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!".colorize(:green)
65
+ puts "="*60
66
+
67
+ puts "\nπŸ“‹ μ„€μ • μš”μ•½:".colorize(:yellow)
68
+ puts "━"*40
69
+ puts "곡인 IP: #{network.public_ip}".colorize(:white)
70
+ puts "λ‚΄λΆ€ IP: #{network.internal_ip}".colorize(:white)
71
+ puts "Traefik: 80, 443 포트 μ‚¬μš© 쀑".colorize(:white)
72
+ puts "λŒ€μ‹œλ³΄λ“œ: http://localhost:8080".colorize(:white)
73
+ puts "━"*40
74
+
75
+ puts "\n🌐 ν™œμ„±ν™”λœ 도메인:".colorize(:yellow)
76
+ domains.each do |domain|
77
+ if network.use_custom_ports?
78
+ puts "β€’ #{domain}".colorize(:cyan)
79
+ puts " HTTP: http://#{domain}:#{network.external_http}".colorize(:gray)
80
+ puts " HTTPS: https://#{domain}:#{network.external_https}".colorize(:gray)
81
+ else
82
+ puts "β€’ #{domain}".colorize(:cyan)
83
+ puts " HTTP: http://#{domain}".colorize(:gray)
84
+ puts " HTTPS: https://#{domain}".colorize(:gray)
85
+ end
86
+ end
87
+
88
+ if network.use_custom_ports?
89
+ puts "\nπŸ’‘ 곡유기 ν¬νŠΈν¬μ›Œλ”©μ„ μ„€μ •ν•˜μ„Έμš”!".colorize(:yellow)
90
+ network.show_port_forwarding_guide
91
+ end
92
+
93
+ puts "\nπŸŽ‰ λͺ¨λ“  섀정이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!".colorize(:green)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
4
+ require "tty-prompt"
5
+ require "net/http"
6
+ require "json"
7
+ require "uri"
8
+ require "fileutils"
9
+
10
+ module Tayo
11
+ module Proxy
12
+ class CloudflareClient
13
+ TOKEN_FILE = File.expand_path("~/.tayo/cloudflare_token")
14
+
15
+ def initialize
16
+ @prompt = TTY::Prompt.new
17
+ end
18
+
19
+ def ensure_token
20
+ @token = load_saved_token
21
+
22
+ if @token
23
+ puts "πŸ”‘ μ €μž₯된 Cloudflare 토큰을 ν™•μΈν•©λ‹ˆλ‹€...".colorize(:yellow)
24
+
25
+ if test_token(@token)
26
+ puts "βœ… μ €μž₯된 Cloudflare 토큰이 μœ νš¨ν•©λ‹ˆλ‹€.".colorize(:green)
27
+ else
28
+ puts "⚠️ μ €μž₯된 토큰이 λ§Œλ£Œλ˜μ—ˆκ±°λ‚˜ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.".colorize(:yellow)
29
+ puts "μƒˆ 토큰을 μž…λ ₯ν•΄μ£Όμ„Έμš”.".colorize(:cyan)
30
+ open_token_creation_page
31
+ @token = get_cloudflare_token
32
+ save_token(@token)
33
+ end
34
+ else
35
+ puts "\nπŸ”‘ Cloudflare API 토큰이 ν•„μš”ν•©λ‹ˆλ‹€.".colorize(:yellow)
36
+ open_token_creation_page
37
+ @token = get_cloudflare_token
38
+ save_token(@token)
39
+ end
40
+ end
41
+
42
+ def select_domains
43
+ puts "\n🌐 Cloudflare 도메인 λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€...".colorize(:yellow)
44
+
45
+ zones = get_cloudflare_zones
46
+
47
+ if zones.empty?
48
+ puts "❌ Cloudflare에 λ“±λ‘λœ 도메인이 μ—†μŠ΅λ‹ˆλ‹€.".colorize(:red)
49
+ puts "λ¨Όμ € https://dash.cloudflare.com μ—μ„œ 도메인을 μΆ”κ°€ν•΄μ£Όμ„Έμš”.".colorize(:cyan)
50
+ return []
51
+ end
52
+
53
+ zone_choices = zones.map { |zone| "#{zone['name']} (#{zone['status']})" }
54
+
55
+ selected_zones = @prompt.multi_select(
56
+ "Caddy둜 λΌμš°νŒ…ν•  도메인을 μ„ νƒν•˜μ„Έμš” (Space둜 선택, Enter둜 μ™„λ£Œ):",
57
+ zone_choices,
58
+ per_page: 10,
59
+ min: 1
60
+ )
61
+
62
+ # μ„ νƒλœ ν•­λͺ©μ΄ μ—†μœΌλ©΄ 빈 λ°°μ—΄ λ°˜ν™˜
63
+ return [] if selected_zones.nil? || selected_zones.empty?
64
+
65
+ puts "DEBUG: selected_zones = #{selected_zones.inspect}" if ENV["DEBUG"]
66
+
67
+ selected_domains = selected_zones.map do |selection|
68
+ zone_name = selection.split(' ').first
69
+ zone = zones.find { |z| z['name'] == zone_name }
70
+
71
+ # μ„œλΈŒλ„λ©”μΈ μΆ”κ°€ μ—¬λΆ€ 확인
72
+ if @prompt.yes?("#{zone_name}에 μ„œλΈŒλ„λ©”μΈμ„ μΆ”κ°€ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")
73
+ subdomain = @prompt.ask("μ„œλΈŒλ„λ©”μΈμ„ μž…λ ₯ν•˜μ„Έμš” (예: app, api):")
74
+ if subdomain && !subdomain.empty?
75
+ {
76
+ domain: "#{subdomain}.#{zone_name}",
77
+ zone_id: zone['id'],
78
+ zone_name: zone_name
79
+ }
80
+ else
81
+ {
82
+ domain: zone_name,
83
+ zone_id: zone['id'],
84
+ zone_name: zone_name
85
+ }
86
+ end
87
+ else
88
+ {
89
+ domain: zone_name,
90
+ zone_id: zone['id'],
91
+ zone_name: zone_name
92
+ }
93
+ end
94
+ end
95
+
96
+ puts "\nβœ… μ„ νƒλœ 도메인:".colorize(:green)
97
+ selected_domains.each do |d|
98
+ puts " β€’ #{d[:domain]}".colorize(:cyan)
99
+ end
100
+
101
+ puts "DEBUG: returning selected_domains = #{selected_domains.inspect}" if ENV["DEBUG"]
102
+
103
+ selected_domains
104
+ end
105
+
106
+ def setup_dns_records(domains, public_ip)
107
+ domains.each do |domain_info|
108
+ domain = domain_info[:domain]
109
+ zone_id = domain_info[:zone_id]
110
+
111
+ puts "\nβš™οΈ #{domain}의 DNS λ ˆμ½”λ“œλ₯Ό μ„€μ •ν•©λ‹ˆλ‹€...".colorize(:yellow)
112
+
113
+ # κΈ°μ‘΄ A λ ˆμ½”λ“œ 확인
114
+ existing_records = get_dns_records(zone_id, domain, ['A'])
115
+
116
+ if existing_records.any?
117
+ existing_record = existing_records.first
118
+ if existing_record['content'] == public_ip
119
+ puts "βœ… #{domain} β†’ #{public_ip} (이미 섀정됨)".colorize(:green)
120
+ else
121
+ puts "⚠️ κΈ°μ‘΄ λ ˆμ½”λ“œλ₯Ό μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€.".colorize(:yellow)
122
+ update_dns_record(zone_id, existing_record['id'], public_ip)
123
+ end
124
+ else
125
+ # μƒˆ A λ ˆμ½”λ“œ 생성
126
+ create_dns_record(zone_id, domain, 'A', public_ip)
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def load_saved_token
134
+ return nil unless File.exist?(TOKEN_FILE)
135
+ File.read(TOKEN_FILE).strip
136
+ rescue
137
+ nil
138
+ end
139
+
140
+ def save_token(token)
141
+ dir = File.dirname(TOKEN_FILE)
142
+
143
+ # 디렉토리가 파일둜 μ‘΄μž¬ν•˜λŠ” 경우 μ‚­μ œ
144
+ if File.exist?(dir) && !File.directory?(dir)
145
+ FileUtils.rm(dir)
146
+ end
147
+
148
+ # 디렉토리가 없을 λ•Œλ§Œ 생성
149
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
150
+
151
+ File.write(TOKEN_FILE, token)
152
+ File.chmod(0600, TOKEN_FILE)
153
+ puts "βœ… API 토큰이 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.".colorize(:green)
154
+ end
155
+
156
+ def open_token_creation_page
157
+ puts "토큰 생성 νŽ˜μ΄μ§€λ₯Ό μ—½λ‹ˆλ‹€...".colorize(:cyan)
158
+ system("open 'https://dash.cloudflare.com/profile/api-tokens' 2>/dev/null || xdg-open 'https://dash.cloudflare.com/profile/api-tokens' 2>/dev/null")
159
+
160
+ puts "\nλ‹€μŒ κΆŒν•œμœΌλ‘œ 토큰을 μƒμ„±ν•΄μ£Όμ„Έμš”:".colorize(:yellow)
161
+ puts ""
162
+ puts "ν•œκ΅­μ–΄ ν™”λ©΄:".colorize(:gray)
163
+ puts "β€’ μ˜μ—­ β†’ μ˜μ—­ β†’ 읽기".colorize(:white)
164
+ puts "β€’ μ˜μ—­ β†’ DNS β†’ νŽΈμ§‘".colorize(:white)
165
+ puts " (μ˜μ—­ λ¦¬μ†ŒμŠ€λŠ” 'λͺ¨λ“  μ˜μ—­' 선택)".colorize(:gray)
166
+ puts ""
167
+ puts "English:".colorize(:gray)
168
+ puts "β€’ Zone β†’ Zone β†’ Read".colorize(:white)
169
+ puts "β€’ Zone β†’ DNS β†’ Edit".colorize(:white)
170
+ puts " (Zone Resources: Select 'All zones')".colorize(:gray)
171
+ puts ""
172
+ end
173
+
174
+ def get_cloudflare_token
175
+ token = @prompt.mask("μƒμ„±λœ Cloudflare API 토큰을 λΆ™μ—¬λ„£μœΌμ„Έμš”:")
176
+
177
+ if token.nil? || token.strip.empty?
178
+ puts "❌ 토큰이 μž…λ ₯λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.".colorize(:red)
179
+ exit 1
180
+ end
181
+
182
+ if test_token(token.strip)
183
+ puts "βœ… 토큰이 ν™•μΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.".colorize(:green)
184
+ return token.strip
185
+ else
186
+ puts "❌ 토큰이 μ˜¬λ°”λ₯΄μ§€ μ•Šκ±°λ‚˜ κΆŒν•œμ΄ λΆ€μ‘±ν•©λ‹ˆλ‹€.".colorize(:red)
187
+ exit 1
188
+ end
189
+ end
190
+
191
+ def test_token(token)
192
+ uri = URI('https://api.cloudflare.com/client/v4/user/tokens/verify')
193
+ http = Net::HTTP.new(uri.host, uri.port)
194
+ http.use_ssl = true
195
+
196
+ request = Net::HTTP::Get.new(uri)
197
+ request['Authorization'] = "Bearer #{token}"
198
+ request['Content-Type'] = 'application/json'
199
+
200
+ response = http.request(request)
201
+
202
+ if response.code == '200'
203
+ data = JSON.parse(response.body)
204
+ return data['success'] == true
205
+ else
206
+ return false
207
+ end
208
+ rescue => e
209
+ puts "⚠️ 토큰 검증 쀑 였λ₯˜: #{e.message}".colorize(:yellow) if ENV["DEBUG"]
210
+ false
211
+ end
212
+
213
+ def get_cloudflare_zones
214
+ uri = URI('https://api.cloudflare.com/client/v4/zones')
215
+ http = Net::HTTP.new(uri.host, uri.port)
216
+ http.use_ssl = true
217
+
218
+ request = Net::HTTP::Get.new(uri)
219
+ request['Authorization'] = "Bearer #{@token}"
220
+ request['Content-Type'] = 'application/json'
221
+
222
+ response = http.request(request)
223
+
224
+ if response.code == '200'
225
+ data = JSON.parse(response.body)
226
+ return data['result'] || []
227
+ else
228
+ puts "❌ 도메인 λͺ©λ‘ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€: #{response.code}".colorize(:red)
229
+ []
230
+ end
231
+ rescue => e
232
+ puts "❌ API μš”μ²­ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: #{e.message}".colorize(:red)
233
+ []
234
+ end
235
+
236
+ def get_dns_records(zone_id, name, types)
237
+ records = []
238
+
239
+ types.each do |type|
240
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
241
+ uri.query = URI.encode_www_form({
242
+ type: type,
243
+ name: name
244
+ })
245
+
246
+ http = Net::HTTP.new(uri.host, uri.port)
247
+ http.use_ssl = true
248
+
249
+ request = Net::HTTP::Get.new(uri)
250
+ request['Authorization'] = "Bearer #{@token}"
251
+ request['Content-Type'] = 'application/json'
252
+
253
+ response = http.request(request)
254
+
255
+ if response.code == '200'
256
+ data = JSON.parse(response.body)
257
+ records.concat(data['result'] || [])
258
+ end
259
+ end
260
+
261
+ records
262
+ rescue => e
263
+ puts "⚠️ DNS λ ˆμ½”λ“œ 쑰회 쀑 였λ₯˜: #{e.message}".colorize(:yellow)
264
+ []
265
+ end
266
+
267
+ def create_dns_record(zone_id, name, type, content)
268
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records")
269
+ http = Net::HTTP.new(uri.host, uri.port)
270
+ http.use_ssl = true
271
+
272
+ request = Net::HTTP::Post.new(uri)
273
+ request['Authorization'] = "Bearer #{@token}"
274
+ request['Content-Type'] = 'application/json'
275
+
276
+ data = {
277
+ type: type,
278
+ name: name,
279
+ content: content,
280
+ ttl: 300,
281
+ proxied: false
282
+ }
283
+
284
+ request.body = data.to_json
285
+ response = http.request(request)
286
+
287
+ if response.code == '200'
288
+ puts "βœ… #{name} β†’ #{content} (A λ ˆμ½”λ“œ 생성됨)".colorize(:green)
289
+ else
290
+ puts "❌ DNS λ ˆμ½”λ“œ 생성 μ‹€νŒ¨: #{response.code}".colorize(:red)
291
+ puts response.body if ENV["DEBUG"]
292
+ end
293
+ rescue => e
294
+ puts "❌ DNS λ ˆμ½”λ“œ 생성 쀑 였λ₯˜: #{e.message}".colorize(:red)
295
+ end
296
+
297
+ def update_dns_record(zone_id, record_id, new_content)
298
+ uri = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/dns_records/#{record_id}")
299
+ http = Net::HTTP.new(uri.host, uri.port)
300
+ http.use_ssl = true
301
+
302
+ request = Net::HTTP::Patch.new(uri)
303
+ request['Authorization'] = "Bearer #{@token}"
304
+ request['Content-Type'] = 'application/json'
305
+
306
+ data = {
307
+ content: new_content
308
+ }
309
+
310
+ request.body = data.to_json
311
+ response = http.request(request)
312
+
313
+ if response.code == '200'
314
+ puts "βœ… DNS λ ˆμ½”λ“œκ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.".colorize(:green)
315
+ else
316
+ puts "❌ DNS λ ˆμ½”λ“œ μ—…λ°μ΄νŠΈ μ‹€νŒ¨: #{response.code}".colorize(:red)
317
+ end
318
+ rescue => e
319
+ puts "❌ DNS λ ˆμ½”λ“œ μ—…λ°μ΄νŠΈ 쀑 였λ₯˜: #{e.message}".colorize(:red)
320
+ end
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
4
+ require "json"
5
+
6
+ module Tayo
7
+ module Proxy
8
+ class DockerManager
9
+ def check_containers
10
+ puts "\n🐳 Docker μ»¨ν…Œμ΄λ„ˆ μƒνƒœλ₯Ό ν™•μΈν•©λ‹ˆλ‹€...".colorize(:yellow)
11
+
12
+ unless docker_installed?
13
+ puts "❌ Dockerκ°€ μ„€μΉ˜λ˜μ–΄ μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.".colorize(:red)
14
+ puts "https://www.docker.com/get-started μ—μ„œ Dockerλ₯Ό μ„€μΉ˜ν•΄μ£Όμ„Έμš”.".colorize(:cyan)
15
+ exit 1
16
+ end
17
+
18
+ unless docker_running?
19
+ puts "❌ Dockerκ°€ μ‹€ν–‰λ˜κ³  μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.".colorize(:red)
20
+ puts "Docker Desktop을 μ‹€ν–‰ν•΄μ£Όμ„Έμš”.".colorize(:cyan)
21
+ exit 1
22
+ end
23
+
24
+ # Kamal Proxy μƒνƒœ 확인
25
+ check_kamal_proxy_status
26
+
27
+ # Caddy μƒνƒœ 확인
28
+ check_caddy_status
29
+
30
+ puts ""
31
+ end
32
+
33
+ def container_exists?(name)
34
+ output = `docker ps -a --filter "name=^#{name}$" --format "{{.Names}}" 2>/dev/null`.strip
35
+ !output.empty?
36
+ end
37
+
38
+ def container_running?(name)
39
+ output = `docker ps --filter "name=^#{name}$" --format "{{.Names}}" 2>/dev/null`.strip
40
+ !output.empty?
41
+ end
42
+
43
+ def check_port_binding(container, ports)
44
+ return false unless container_running?(container)
45
+
46
+ ports.all? do |port|
47
+ output = `docker port #{container} #{port} 2>/dev/null`.strip
48
+ !output.empty?
49
+ end
50
+ end
51
+
52
+ def port_in_use?(port)
53
+ # Docker μ»¨ν…Œμ΄λ„ˆκ°€ 포트λ₯Ό μ‚¬μš© 쀑인지 확인
54
+ docker_using = `docker ps --format "table {{.Names}}\t{{.Ports}}" | grep -E "0\\.0\\.0\\.0:#{port}->|\\*:#{port}->" 2>/dev/null`.strip
55
+ return true unless docker_using.empty?
56
+
57
+ # μ‹œμŠ€ν…œ 포트 확인
58
+ if RUBY_PLATFORM.include?("darwin")
59
+ # macOS
60
+ output = `lsof -iTCP:#{port} -sTCP:LISTEN 2>/dev/null`.strip
61
+ else
62
+ # Linux
63
+ output = `netstat -tln 2>/dev/null | grep ":#{port}"`.strip
64
+ output = `ss -tln 2>/dev/null | grep ":#{port}"`.strip if output.empty?
65
+ end
66
+
67
+ !output.empty?
68
+ end
69
+
70
+ def stop_container(name)
71
+ return unless container_exists?(name)
72
+
73
+ puts "πŸ›‘ #{name} μ»¨ν…Œμ΄λ„ˆλ₯Ό μ€‘μ§€ν•©λ‹ˆλ‹€...".colorize(:yellow)
74
+ system("docker stop #{name} >/dev/null 2>&1")
75
+ system("docker rm #{name} >/dev/null 2>&1")
76
+ end
77
+
78
+ def get_container_network(name)
79
+ return nil unless container_running?(name)
80
+
81
+ output = `docker inspect #{name} --format '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' 2>/dev/null`.strip
82
+ output.empty? ? nil : output
83
+ end
84
+
85
+ def create_network_if_not_exists(network_name = "tayo-proxy")
86
+ existing = `docker network ls --filter "name=^#{network_name}$" --format "{{.Name}}" 2>/dev/null`.strip
87
+
88
+ if existing.empty?
89
+ puts "πŸ“‘ Docker λ„€νŠΈμ›Œν¬ '#{network_name}'λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€...".colorize(:yellow)
90
+ system("docker network create #{network_name} >/dev/null 2>&1")
91
+ end
92
+
93
+ network_name
94
+ end
95
+
96
+ private
97
+
98
+ def docker_installed?
99
+ system("which docker >/dev/null 2>&1")
100
+ end
101
+
102
+ def docker_running?
103
+ system("docker info >/dev/null 2>&1")
104
+ end
105
+
106
+ def check_kamal_proxy_status
107
+ # Traefik으둜 κ΅μ²΄λ˜μ–΄ 더 이상 μ‚¬μš©ν•˜μ§€ μ•ŠμŒ
108
+ check_traefik_status
109
+ end
110
+
111
+ def check_traefik_status
112
+ if container_running?("traefik")
113
+ if check_port_binding("traefik", [80, 443])
114
+ puts "βœ… Traefik: μ‹€ν–‰ 쀑 (80, 443 포트 μ‚¬μš©)".colorize(:green)
115
+ else
116
+ puts "⚠️ Traefik: μ‹€ν–‰ μ€‘μ΄μ§€λ§Œ ν¬νŠΈκ°€ μ˜¬λ°”λ₯΄κ²Œ λ°”μΈλ”©λ˜μ§€ μ•ŠμŒ".colorize(:yellow)
117
+ show_port_conflicts
118
+ end
119
+ elsif container_exists?("traefik")
120
+ puts "⚠️ Traefik: 쀑지됨".colorize(:yellow)
121
+ else
122
+ puts "ℹ️ Traefik: μ„€μΉ˜λ˜μ§€ μ•ŠμŒ".colorize(:gray)
123
+ end
124
+ end
125
+
126
+ def check_caddy_status
127
+ # CaddyλŠ” 더 이상 μ‚¬μš©ν•˜μ§€ μ•ŠμŒ
128
+ # 이 λ©”μ„œλ“œλŠ” ν•˜μœ„ ν˜Έν™˜μ„±μ„ μœ„ν•΄ μœ μ§€ν•˜μ§€λ§Œ 아무것도 ν•˜μ§€ μ•ŠμŒ
129
+ end
130
+
131
+ def show_port_conflicts
132
+ [80, 443].each do |port|
133
+ if port_in_use?(port)
134
+ # 포트λ₯Ό μ‚¬μš© 쀑인 ν”„λ‘œμ„ΈμŠ€ μ°ΎκΈ°
135
+ if RUBY_PLATFORM.include?("darwin")
136
+ process = `lsof -iTCP:#{port} -sTCP:LISTEN 2>/dev/null | grep LISTEN | head -1`.strip
137
+ else
138
+ process = `netstat -tlnp 2>/dev/null | grep ":#{port}" | head -1`.strip
139
+ end
140
+
141
+ unless process.empty?
142
+ puts " ⚠️ 포트 #{port}κ°€ λ‹€λ₯Έ ν”„λ‘œμ„ΈμŠ€μ—μ„œ μ‚¬μš© μ€‘μž…λ‹ˆλ‹€:".colorize(:yellow)
143
+ puts " #{process}".colorize(:gray)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end