easy_caddy 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/exe/ecaddy +6 -0
- data/lib/easy_caddy/caddy.rb +69 -0
- data/lib/easy_caddy/cli.rb +107 -0
- data/lib/easy_caddy/commands/audit.rb +576 -0
- data/lib/easy_caddy/commands/doctor.rb +34 -0
- data/lib/easy_caddy/commands/down.rb +42 -0
- data/lib/easy_caddy/commands/edit.rb +36 -0
- data/lib/easy_caddy/commands/ensure.rb +22 -0
- data/lib/easy_caddy/commands/list.rb +53 -0
- data/lib/easy_caddy/commands/logs.rb +63 -0
- data/lib/easy_caddy/commands/register_helpers.rb +116 -0
- data/lib/easy_caddy/commands/reload.rb +16 -0
- data/lib/easy_caddy/commands/remove.rb +41 -0
- data/lib/easy_caddy/commands/run.rb +35 -0
- data/lib/easy_caddy/commands/setup.rb +104 -0
- data/lib/easy_caddy/commands/status.rb +39 -0
- data/lib/easy_caddy/commands/up.rb +42 -0
- data/lib/easy_caddy/conflicts.rb +152 -0
- data/lib/easy_caddy/parser.rb +39 -0
- data/lib/easy_caddy/paths.rb +18 -0
- data/lib/easy_caddy/registry.rb +49 -0
- data/lib/easy_caddy/site.rb +13 -0
- data/lib/easy_caddy/version.rb +5 -0
- data/lib/easy_caddy.rb +13 -0
- metadata +173 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
require 'tty-prompt'
|
|
8
|
+
require_relative '../registry'
|
|
9
|
+
require_relative '../paths'
|
|
10
|
+
require_relative '../caddy'
|
|
11
|
+
require_relative '../parser'
|
|
12
|
+
require_relative '../conflicts'
|
|
13
|
+
|
|
14
|
+
module EasyCaddy
|
|
15
|
+
module Commands
|
|
16
|
+
# Prints a full system + TLS + site snapshot with per-domain TLS handshake probes.
|
|
17
|
+
# With fix: true, prompts to apply a remedy for each actionable finding, with
|
|
18
|
+
# automatic escalation to a chained next_fix when the primary fix doesn't resolve it.
|
|
19
|
+
# rubocop:disable Metrics/ClassLength
|
|
20
|
+
class Audit
|
|
21
|
+
# ANSI colours — fall back gracefully if $stdout is not a TTY.
|
|
22
|
+
RED = "\e[31m"
|
|
23
|
+
GREEN = "\e[32m"
|
|
24
|
+
YELLW = "\e[33m"
|
|
25
|
+
RESET = "\e[0m"
|
|
26
|
+
|
|
27
|
+
Fix = Data.define(:label, :description, :command, :verify, :escalation, :next_fix)
|
|
28
|
+
|
|
29
|
+
def initialize(site: nil, fix: false)
|
|
30
|
+
@site_filter = site
|
|
31
|
+
@fix_mode = fix
|
|
32
|
+
@fixes = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
section('SYSTEM')
|
|
37
|
+
print_system
|
|
38
|
+
|
|
39
|
+
section('TLS READINESS')
|
|
40
|
+
print_tls_readiness
|
|
41
|
+
|
|
42
|
+
section('SITES')
|
|
43
|
+
print_sites
|
|
44
|
+
|
|
45
|
+
section('CONFLICTS')
|
|
46
|
+
print_conflicts
|
|
47
|
+
|
|
48
|
+
run_fixes if @fix_mode
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# ── formatting helpers ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def section(title)
|
|
56
|
+
puts
|
|
57
|
+
puts "── #{title} #{'─' * [0, 60 - title.length].max}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ok(msg) = puts(" #{GREEN}✓#{RESET} #{msg}")
|
|
61
|
+
def info(msg) = puts(" #{msg}")
|
|
62
|
+
|
|
63
|
+
# rubocop:disable Metrics/MethodLength
|
|
64
|
+
def fail(msg, hint: nil, fix: nil)
|
|
65
|
+
puts " #{RED}✗#{RESET} #{msg}"
|
|
66
|
+
puts " hint: #{hint}" if hint
|
|
67
|
+
return unless fix
|
|
68
|
+
|
|
69
|
+
@fixes << Fix.new(
|
|
70
|
+
label: msg,
|
|
71
|
+
description: fix[:description],
|
|
72
|
+
command: fix[:command],
|
|
73
|
+
verify: fix[:verify],
|
|
74
|
+
escalation: fix[:escalation],
|
|
75
|
+
next_fix: fix[:next_fix]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
# rubocop:enable Metrics/MethodLength
|
|
79
|
+
|
|
80
|
+
# rubocop:disable Metrics/MethodLength
|
|
81
|
+
def warn(msg, hint: nil, fix: nil)
|
|
82
|
+
puts " #{YELLW}!#{RESET} #{msg}"
|
|
83
|
+
puts " hint: #{hint}" if hint
|
|
84
|
+
return unless fix
|
|
85
|
+
|
|
86
|
+
@fixes << Fix.new(
|
|
87
|
+
label: msg,
|
|
88
|
+
description: fix[:description],
|
|
89
|
+
command: fix[:command],
|
|
90
|
+
verify: fix[:verify],
|
|
91
|
+
escalation: fix[:escalation],
|
|
92
|
+
next_fix: fix[:next_fix]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
# rubocop:enable Metrics/MethodLength
|
|
96
|
+
|
|
97
|
+
# ── system section ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
100
|
+
def print_system
|
|
101
|
+
if Caddy.installed?
|
|
102
|
+
version = `caddy version 2>/dev/null`.strip
|
|
103
|
+
ok("Caddy installed: #{version}")
|
|
104
|
+
else
|
|
105
|
+
fail('Caddy not installed',
|
|
106
|
+
hint: 'Install via Homebrew.',
|
|
107
|
+
fix: {
|
|
108
|
+
description: 'Install Caddy',
|
|
109
|
+
command: 'brew install caddy',
|
|
110
|
+
verify: -> { Caddy.installed? },
|
|
111
|
+
escalation: 'Install still failed. Check `brew doctor`, or install manually from caddyserver.com.',
|
|
112
|
+
next_fix: nil
|
|
113
|
+
})
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
print_service_status
|
|
117
|
+
|
|
118
|
+
caddyfile = Paths.caddyfile
|
|
119
|
+
if caddyfile.exist?
|
|
120
|
+
ok("Global Caddyfile: #{caddyfile}")
|
|
121
|
+
else
|
|
122
|
+
fail("Global Caddyfile missing: #{caddyfile}",
|
|
123
|
+
hint: 'Run `ecaddy setup` to scaffold the global config.',
|
|
124
|
+
fix: {
|
|
125
|
+
description: 'Scaffold global config',
|
|
126
|
+
command: 'ecaddy setup',
|
|
127
|
+
verify: -> { Paths.caddyfile.exist? },
|
|
128
|
+
escalation: 'Setup didn\'t write the Caddyfile. Re-run `ecaddy setup` and watch for errors.',
|
|
129
|
+
next_fix: nil
|
|
130
|
+
})
|
|
131
|
+
end
|
|
132
|
+
brew_link = Paths.brew_caddyfile
|
|
133
|
+
if brew_link.symlink? && brew_link.readlink == caddyfile
|
|
134
|
+
ok("Brew symlink: #{brew_link} → #{caddyfile}")
|
|
135
|
+
elsif brew_link.exist?
|
|
136
|
+
warn("Brew symlink #{brew_link} points elsewhere",
|
|
137
|
+
hint: 'Run `ecaddy setup` to fix the symlink.',
|
|
138
|
+
fix: {
|
|
139
|
+
description: 'Fix brew symlink',
|
|
140
|
+
command: 'ecaddy setup',
|
|
141
|
+
verify: -> { Paths.brew_caddyfile.symlink? && Paths.brew_caddyfile.readlink == Paths.caddyfile },
|
|
142
|
+
escalation: 'Symlink still wrong. Remove it manually ' \
|
|
143
|
+
'(`rm /opt/homebrew/etc/Caddyfile`) and re-run `ecaddy setup`.',
|
|
144
|
+
next_fix: nil
|
|
145
|
+
})
|
|
146
|
+
else
|
|
147
|
+
fail("Brew symlink missing: #{brew_link}",
|
|
148
|
+
hint: 'Run `ecaddy setup` to create the symlink.',
|
|
149
|
+
fix: {
|
|
150
|
+
description: 'Create brew symlink',
|
|
151
|
+
command: 'ecaddy setup',
|
|
152
|
+
verify: -> { Paths.brew_caddyfile.symlink? && Paths.brew_caddyfile.readlink == Paths.caddyfile },
|
|
153
|
+
escalation: 'Symlink still missing after setup. Re-run `ecaddy setup` and watch for errors.',
|
|
154
|
+
next_fix: nil
|
|
155
|
+
})
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
159
|
+
|
|
160
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
161
|
+
def print_service_status
|
|
162
|
+
brew_pid = Caddy.brew_service_pid
|
|
163
|
+
proc_pid = Caddy.process_pid
|
|
164
|
+
|
|
165
|
+
if brew_pid
|
|
166
|
+
ok("brew service running (PID #{brew_pid})")
|
|
167
|
+
elsif proc_pid
|
|
168
|
+
warn("brew service not started, but Caddy process #{proc_pid} is running",
|
|
169
|
+
hint: 'Caddy was started outside brew (e.g. `caddy run` or sudo). ' \
|
|
170
|
+
'Ports :443/:80 will work, but it won\'t restart automatically. ' \
|
|
171
|
+
'To switch to the brew-managed service: stop it and run `brew services start caddy`.',
|
|
172
|
+
fix: {
|
|
173
|
+
description: 'Stop external Caddy + start brew service',
|
|
174
|
+
command: "pkill -f 'caddy run'; brew services start caddy",
|
|
175
|
+
verify: -> { Caddy.brew_service_pid },
|
|
176
|
+
escalation: 'Brew service still not running — external Caddy may have ignored SIGTERM.',
|
|
177
|
+
next_fix: Fix.new(
|
|
178
|
+
label: 'External Caddy did not stop',
|
|
179
|
+
description: 'Force-kill external Caddy and restart via sudo',
|
|
180
|
+
command: "sudo pkill -9 -f 'caddy run'; sudo brew services restart caddy",
|
|
181
|
+
verify: -> { port_open?(443) || Caddy.brew_service_pid },
|
|
182
|
+
escalation: 'Still not running. Check `brew services info caddy`.',
|
|
183
|
+
next_fix: nil
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
else
|
|
187
|
+
fail('Caddy is not running',
|
|
188
|
+
hint: 'Start the brew service: `brew services start caddy`.',
|
|
189
|
+
fix: {
|
|
190
|
+
description: 'Start Caddy via brew',
|
|
191
|
+
command: 'brew services start caddy',
|
|
192
|
+
verify: -> { Caddy.brew_service_pid },
|
|
193
|
+
escalation: 'Service still not up — brew user-mode may lack permission to bind low ports.',
|
|
194
|
+
next_fix: Fix.new(
|
|
195
|
+
label: 'Caddy still not running — trying with elevated privileges',
|
|
196
|
+
description: 'Start Caddy via sudo (creates a root LaunchDaemon, binds low ports)',
|
|
197
|
+
command: 'brew services stop caddy; sudo brew services start caddy',
|
|
198
|
+
verify: -> { port_open?(443) || Caddy.brew_service_pid },
|
|
199
|
+
escalation: 'Still not running. Check `brew services info caddy`.',
|
|
200
|
+
next_fix: nil
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
206
|
+
|
|
207
|
+
# ── TLS readiness section ───────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
# rubocop:disable Metrics/MethodLength
|
|
210
|
+
def print_tls_readiness
|
|
211
|
+
if caddy_ca_trusted?
|
|
212
|
+
ok('Caddy Local Authority found in system keychain')
|
|
213
|
+
else
|
|
214
|
+
fail('Caddy Local Authority not found in keychain',
|
|
215
|
+
hint: 'Run `caddy trust` to install the local root CA. ' \
|
|
216
|
+
'Without it, browsers show ERR_SSL_PROTOCOL_ERROR or NET::ERR_CERT_AUTHORITY_INVALID.',
|
|
217
|
+
fix: {
|
|
218
|
+
description: 'Trust Caddy local CA',
|
|
219
|
+
command: 'caddy trust',
|
|
220
|
+
verify: -> { caddy_ca_trusted? },
|
|
221
|
+
escalation: 'CA not installed — installation into System keychain requires admin.',
|
|
222
|
+
next_fix: Fix.new(
|
|
223
|
+
label: 'Caddy CA still not trusted — trying with sudo',
|
|
224
|
+
description: 'Install CA into System keychain via sudo',
|
|
225
|
+
command: 'sudo caddy trust',
|
|
226
|
+
verify: -> { caddy_ca_trusted? },
|
|
227
|
+
escalation: 'CA still not in keychain. Check Keychain Access for \'Caddy Local Authority\'.',
|
|
228
|
+
next_fix: nil
|
|
229
|
+
)
|
|
230
|
+
})
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
check_port(443, 'HTTPS (:443)')
|
|
234
|
+
check_port(80, 'HTTP (:80)')
|
|
235
|
+
end
|
|
236
|
+
# rubocop:enable Metrics/MethodLength
|
|
237
|
+
|
|
238
|
+
def caddy_ca_trusted?
|
|
239
|
+
# `find-certificate -p` dumps PEM; `verify-cert -p ssl` checks trust settings —
|
|
240
|
+
# unlike bare `find-certificate` which only checks for presence.
|
|
241
|
+
system(
|
|
242
|
+
'security find-certificate -c "Caddy Local Authority" -p ' \
|
|
243
|
+
'/Library/Keychains/System.keychain 2>/dev/null | ' \
|
|
244
|
+
'security verify-cert -c /dev/stdin -p ssl > /dev/null 2>&1'
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def browser_trusts?(domain)
|
|
249
|
+
# curl on macOS uses Secure Transport (same trust store as Chrome/Safari).
|
|
250
|
+
# --resolve avoids DNS edge cases with .localhost
|
|
251
|
+
system(
|
|
252
|
+
"curl --silent --show-error --max-time 2 --resolve #{domain}:443:127.0.0.1 " \
|
|
253
|
+
"-o /dev/null https://#{domain}/ 2>/dev/null"
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def port_open?(port)
|
|
258
|
+
Timeout.timeout(0.5) { TCPSocket.new('127.0.0.1', port).close }
|
|
259
|
+
true
|
|
260
|
+
rescue StandardError
|
|
261
|
+
false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# rubocop:disable Metrics/MethodLength
|
|
265
|
+
def check_port(port, label)
|
|
266
|
+
if port_open?(port)
|
|
267
|
+
ok("#{label} is bound")
|
|
268
|
+
else
|
|
269
|
+
fail("#{label} is NOT bound",
|
|
270
|
+
hint: 'Caddy may need `sudo` to bind low ports, or is not running.',
|
|
271
|
+
fix: {
|
|
272
|
+
description: "Restart Caddy (to bind #{label})",
|
|
273
|
+
command: 'brew services restart caddy',
|
|
274
|
+
verify: -> { port_open?(port) },
|
|
275
|
+
escalation: "Port :#{port} still not bound — brew user-mode cannot bind ports below 1024.",
|
|
276
|
+
next_fix: Fix.new(
|
|
277
|
+
label: "#{label} still not bound — needs elevated privileges",
|
|
278
|
+
description: 'Stop user-mode Caddy and start as root (binds low ports). ' \
|
|
279
|
+
'Prompts for admin password.',
|
|
280
|
+
command: 'brew services stop caddy; sudo brew services restart caddy',
|
|
281
|
+
verify: -> { port_open?(port) },
|
|
282
|
+
escalation: "Still not bound. Another process may own :#{port} — " \
|
|
283
|
+
"check `sudo lsof -nP -i :#{port}`.",
|
|
284
|
+
next_fix: nil
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
# rubocop:enable Metrics/MethodLength
|
|
290
|
+
|
|
291
|
+
# ── sites section ───────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def print_sites
|
|
294
|
+
registry = Registry.load
|
|
295
|
+
sites = registry.all
|
|
296
|
+
sites = sites.select { |s| s.name == @site_filter } if @site_filter
|
|
297
|
+
|
|
298
|
+
if sites.empty?
|
|
299
|
+
info(@site_filter ? "No site '#{@site_filter}' in registry." : 'No sites registered.')
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
sites.each { |s| print_site(s) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
307
|
+
def print_site(site)
|
|
308
|
+
puts
|
|
309
|
+
puts " Site: #{site.name} [#{site.enabled ? 'enabled' : 'disabled'}]"
|
|
310
|
+
info "source: #{site.source_path || '(none)'}"
|
|
311
|
+
|
|
312
|
+
fragment = site.enabled ? Paths.site_file(site.name) : Paths.disabled_file(site.name)
|
|
313
|
+
unless fragment.exist?
|
|
314
|
+
re_register_cmd = site.source_path ? "ecaddy ensure -c #{site.source_path} -s #{site.name}" : nil
|
|
315
|
+
fail("fragment missing: #{fragment}",
|
|
316
|
+
hint: 'Fragment file was deleted. Re-register from source.',
|
|
317
|
+
fix: if re_register_cmd
|
|
318
|
+
{
|
|
319
|
+
description: 'Re-register from source',
|
|
320
|
+
command: re_register_cmd,
|
|
321
|
+
verify: -> { fragment.exist? },
|
|
322
|
+
escalation: 'Re-register didn\'t write the fragment. ' \
|
|
323
|
+
'Check the source path exists and is readable.',
|
|
324
|
+
next_fix: nil
|
|
325
|
+
}
|
|
326
|
+
end)
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
info "fragment: #{fragment}"
|
|
331
|
+
parsed = Parser.parse(File.read(fragment))
|
|
332
|
+
|
|
333
|
+
print_domains(parsed.domains)
|
|
334
|
+
print_upstreams(parsed.ports)
|
|
335
|
+
print_log_files(parsed.log_paths)
|
|
336
|
+
end
|
|
337
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
338
|
+
|
|
339
|
+
# rubocop:disable Metrics/MethodLength
|
|
340
|
+
def print_domains(domains)
|
|
341
|
+
if domains.empty?
|
|
342
|
+
warn 'No .localhost domains found in fragment'
|
|
343
|
+
return
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
domains.each do |domain|
|
|
347
|
+
handshake_ok, detail = tls_probe(domain)
|
|
348
|
+
unless handshake_ok
|
|
349
|
+
hint, fix = tls_hint_and_fix(detail, domain)
|
|
350
|
+
fail("#{domain} — TLS ✗ #{detail}", hint: hint, fix: fix)
|
|
351
|
+
next
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
if browser_trusts?(domain)
|
|
355
|
+
ok("#{domain} — TLS ✓ browser-trusted ✓ #{detail}")
|
|
356
|
+
else
|
|
357
|
+
fail("#{domain} — TLS ✓ browser-trust ✗ (Chrome will show ERR_CERT_AUTHORITY_INVALID)",
|
|
358
|
+
hint: 'Caddy CA is not installed into the System keychain with SSL trust. ' \
|
|
359
|
+
'Run `sudo caddy trust`.',
|
|
360
|
+
fix: browser_trust_fix(domain))
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def browser_trust_fix(domain)
|
|
366
|
+
{
|
|
367
|
+
description: 'Install Caddy CA into System keychain',
|
|
368
|
+
command: 'caddy trust',
|
|
369
|
+
verify: -> { browser_trusts?(domain) },
|
|
370
|
+
escalation: 'CA still not browser-trusted — System-keychain install requires admin.',
|
|
371
|
+
next_fix: Fix.new(
|
|
372
|
+
label: "#{domain} browser-trust still failing — needs sudo",
|
|
373
|
+
description: 'Install CA into System keychain via sudo (browsers will honor it)',
|
|
374
|
+
command: 'sudo caddy trust',
|
|
375
|
+
verify: -> { browser_trusts?(domain) },
|
|
376
|
+
escalation: 'Still not trusted. Open Keychain Access → System → search "Caddy Local Authority" → ' \
|
|
377
|
+
'set Trust → "When using this certificate: Always Trust".',
|
|
378
|
+
next_fix: nil
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def print_upstreams(ports)
|
|
384
|
+
if ports.empty?
|
|
385
|
+
info 'No reverse_proxy upstreams found'
|
|
386
|
+
else
|
|
387
|
+
ports.each do |port|
|
|
388
|
+
if tcp_open?(port)
|
|
389
|
+
ok("upstream localhost:#{port} — listening")
|
|
390
|
+
else
|
|
391
|
+
warn("upstream localhost:#{port} — NOT listening",
|
|
392
|
+
hint: "Start your app on port #{port}.")
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def print_log_files(log_paths)
|
|
399
|
+
if log_paths.empty?
|
|
400
|
+
info 'No log files configured (add a log { output file … } block)'
|
|
401
|
+
else
|
|
402
|
+
log_paths.each do |path|
|
|
403
|
+
if File.exist?(path)
|
|
404
|
+
ok("log #{path} (#{humanize_bytes(File.size(path))})")
|
|
405
|
+
else
|
|
406
|
+
warn("log #{path} — not yet created")
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
# rubocop:enable Metrics/MethodLength
|
|
412
|
+
|
|
413
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
414
|
+
def tls_hint_and_fix(detail, domain = nil)
|
|
415
|
+
case detail
|
|
416
|
+
when /connection refused/
|
|
417
|
+
hint = 'Caddy is not listening on :443. Start the service.'
|
|
418
|
+
fix = {
|
|
419
|
+
description: 'Start Caddy',
|
|
420
|
+
command: 'brew services start caddy',
|
|
421
|
+
verify: -> { port_open?(443) },
|
|
422
|
+
escalation: 'Caddy still not on :443 — see the port-binding hint above.',
|
|
423
|
+
next_fix: nil
|
|
424
|
+
}
|
|
425
|
+
when /internal error|alert 80/
|
|
426
|
+
hint = 'Caddy aborted the TLS handshake — usually stale on-demand issuance state. ' \
|
|
427
|
+
'Try a reload; if that fails, restart Caddy.'
|
|
428
|
+
fix = {
|
|
429
|
+
description: 'Reload Caddy config',
|
|
430
|
+
command: "caddy reload --config #{Paths.brew_caddyfile}",
|
|
431
|
+
verify: domain ? -> { tls_probe(domain).first } : nil,
|
|
432
|
+
escalation: 'Reload didn\'t clear it. Try `brew services restart caddy`. ' \
|
|
433
|
+
'If still failing, the on-demand cert store may be corrupt — ' \
|
|
434
|
+
'see `~/Library/Application Support/Caddy/pki/`.',
|
|
435
|
+
next_fix: nil
|
|
436
|
+
}
|
|
437
|
+
when /unknown ca|certificate|authority/i
|
|
438
|
+
hint = "Caddy's local CA is not trusted by this machine."
|
|
439
|
+
fix = {
|
|
440
|
+
description: 'Trust Caddy local CA',
|
|
441
|
+
command: 'caddy trust',
|
|
442
|
+
verify: domain ? -> { browser_trusts?(domain) } : -> { caddy_ca_trusted? },
|
|
443
|
+
escalation: 'CA not installed — installation into System keychain requires admin.',
|
|
444
|
+
next_fix: Fix.new(
|
|
445
|
+
label: 'Caddy CA still not trusted — trying with sudo',
|
|
446
|
+
description: 'Install CA into System keychain via sudo',
|
|
447
|
+
command: 'sudo caddy trust',
|
|
448
|
+
verify: domain ? -> { browser_trusts?(domain) } : -> { caddy_ca_trusted? },
|
|
449
|
+
escalation: 'CA still not in keychain. Check Keychain Access for \'Caddy Local Authority\'.',
|
|
450
|
+
next_fix: nil
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
else
|
|
454
|
+
hint = nil
|
|
455
|
+
fix = nil
|
|
456
|
+
end
|
|
457
|
+
[hint, fix]
|
|
458
|
+
end
|
|
459
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
460
|
+
|
|
461
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
462
|
+
def tls_probe(domain)
|
|
463
|
+
Timeout.timeout(1) do
|
|
464
|
+
tcp = TCPSocket.new('localhost', 443)
|
|
465
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
466
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
467
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
468
|
+
ssl.hostname = domain
|
|
469
|
+
ssl.sync_close = true
|
|
470
|
+
ssl.connect
|
|
471
|
+
cn = ssl.peer_cert&.subject&.to_a&.find { |name, _| name == 'CN' }&.at(1) || '?'
|
|
472
|
+
ssl.close
|
|
473
|
+
[true, "cert CN=#{cn}"]
|
|
474
|
+
end
|
|
475
|
+
rescue Errno::ECONNREFUSED
|
|
476
|
+
[false, 'connection refused on :443 (Caddy not running or not bound)']
|
|
477
|
+
rescue StandardError => e
|
|
478
|
+
[false, e.message.split("\n").first]
|
|
479
|
+
end
|
|
480
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
481
|
+
|
|
482
|
+
def tcp_open?(port)
|
|
483
|
+
TCPSocket.new('localhost', port).close
|
|
484
|
+
true
|
|
485
|
+
rescue StandardError
|
|
486
|
+
false
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def humanize_bytes(bytes)
|
|
490
|
+
return "#{bytes} B" if bytes < 1024
|
|
491
|
+
return "#{(bytes / 1024.0).round(1)} KB" if bytes < 1_048_576
|
|
492
|
+
|
|
493
|
+
"#{(bytes / 1_048_576.0).round(1)} MB"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# ── conflicts section ───────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
# rubocop:disable Metrics/MethodLength
|
|
499
|
+
def print_conflicts
|
|
500
|
+
registry = Registry.load
|
|
501
|
+
findings = Conflicts.doctor(registry: registry)
|
|
502
|
+
|
|
503
|
+
if findings.empty?
|
|
504
|
+
ok('No conflicts or dead upstreams detected')
|
|
505
|
+
return
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
findings.each do |f|
|
|
509
|
+
case f.severity
|
|
510
|
+
when 'BLOCK' then fail("#{f.message} Hint: #{f.hint}")
|
|
511
|
+
when 'WARN' then warn("#{f.message} Hint: #{f.hint}")
|
|
512
|
+
else info("#{f.message} Hint: #{f.hint}")
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
# rubocop:enable Metrics/MethodLength
|
|
517
|
+
|
|
518
|
+
# ── fix loop ────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
def run_fixes
|
|
521
|
+
return if @fixes.empty?
|
|
522
|
+
|
|
523
|
+
prompt = TTY::Prompt.new
|
|
524
|
+
@applied_commands = []
|
|
525
|
+
|
|
526
|
+
section("FIXES (#{@fixes.length})")
|
|
527
|
+
@fixes.each { |fix| run_fix(fix, prompt) }
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
531
|
+
def run_fix(fix, prompt)
|
|
532
|
+
if fix.verify&.call
|
|
533
|
+
puts
|
|
534
|
+
puts " #{GREEN}✓#{RESET} #{fix.label} — already resolved"
|
|
535
|
+
return
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
puts
|
|
539
|
+
puts " Issue: #{fix.label}"
|
|
540
|
+
puts " Fix: #{fix.description}"
|
|
541
|
+
puts " Command: #{fix.command}"
|
|
542
|
+
return unless prompt.yes?(' Apply?')
|
|
543
|
+
|
|
544
|
+
if @applied_commands.include?(fix.command)
|
|
545
|
+
puts " #{YELLW}!#{RESET} already ran this command in this session — re-checking..."
|
|
546
|
+
else
|
|
547
|
+
ran = system(fix.command)
|
|
548
|
+
unless ran
|
|
549
|
+
puts " #{RED}✗#{RESET} command failed to run"
|
|
550
|
+
puts " next: #{fix.escalation}" if fix.escalation
|
|
551
|
+
run_fix(fix.next_fix, prompt) if fix.next_fix
|
|
552
|
+
return
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
@applied_commands << fix.command
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
if fix.verify.nil?
|
|
559
|
+
puts " #{GREEN}✓#{RESET} applied"
|
|
560
|
+
return
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
sleep 0.5
|
|
564
|
+
if fix.verify.call
|
|
565
|
+
puts " #{GREEN}✓#{RESET} applied and verified"
|
|
566
|
+
else
|
|
567
|
+
puts " #{YELLW}!#{RESET} applied, but the issue is still present"
|
|
568
|
+
puts " next: #{fix.escalation}" if fix.escalation
|
|
569
|
+
run_fix(fix.next_fix, prompt) if fix.next_fix
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
573
|
+
end
|
|
574
|
+
# rubocop:enable Metrics/ClassLength
|
|
575
|
+
end
|
|
576
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../registry'
|
|
4
|
+
require_relative '../conflicts'
|
|
5
|
+
|
|
6
|
+
module EasyCaddy
|
|
7
|
+
module Commands
|
|
8
|
+
class Doctor
|
|
9
|
+
def call
|
|
10
|
+
registry = Registry.load
|
|
11
|
+
findings = Conflicts.doctor(registry: registry)
|
|
12
|
+
|
|
13
|
+
if findings.empty?
|
|
14
|
+
puts ' All clear — no conflicts or dead upstreams detected.'
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
has_block = false
|
|
19
|
+
findings.each do |f|
|
|
20
|
+
label = case f.severity
|
|
21
|
+
when 'BLOCK' then "\e[31mBLOCK\e[0m"
|
|
22
|
+
when 'WARN' then "\e[33mWARN \e[0m"
|
|
23
|
+
else "\e[34mINFO \e[0m"
|
|
24
|
+
end
|
|
25
|
+
puts " #{label} #{f.message}"
|
|
26
|
+
puts " → #{f.hint}"
|
|
27
|
+
has_block = true if f.severity == 'BLOCK'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
exit 1 if has_block
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../paths'
|
|
4
|
+
require_relative '../registry'
|
|
5
|
+
require_relative '../caddy'
|
|
6
|
+
require_relative '../site'
|
|
7
|
+
|
|
8
|
+
module EasyCaddy
|
|
9
|
+
module Commands
|
|
10
|
+
class Down
|
|
11
|
+
def initialize(name:)
|
|
12
|
+
@name = name.downcase
|
|
13
|
+
@registry = Registry.load
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
site = @registry.find(@name)
|
|
18
|
+
unless site
|
|
19
|
+
warn " Site '#{@name}' is not registered."
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if !site.enabled
|
|
24
|
+
puts " '#{@name}' is already down."
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
active = Paths.site_file(@name)
|
|
29
|
+
unless active.exist?
|
|
30
|
+
warn " Fragment not found in sites/: #{active}"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Paths.disabled_dir.mkpath
|
|
35
|
+
active.rename(Paths.disabled_file(@name))
|
|
36
|
+
@registry.update(Site.new(name: site.name, enabled: false, source_path: site.source_path))
|
|
37
|
+
Caddy.reload(Paths.caddyfile)
|
|
38
|
+
puts " '#{@name}' is down. Run `ecaddy up #{@name}` to bring it back."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../paths'
|
|
4
|
+
require_relative '../registry'
|
|
5
|
+
require_relative '../caddy'
|
|
6
|
+
|
|
7
|
+
module EasyCaddy
|
|
8
|
+
module Commands
|
|
9
|
+
class Edit
|
|
10
|
+
def initialize(name:)
|
|
11
|
+
@name = name.downcase
|
|
12
|
+
@registry = Registry.load
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
site = @registry.find(@name)
|
|
17
|
+
unless site
|
|
18
|
+
warn " Site '#{@name}' is not registered."
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
file = site.enabled ? Paths.site_file(@name) : Paths.disabled_file(@name)
|
|
23
|
+
unless file.exist?
|
|
24
|
+
warn " Fragment file not found: #{file}"
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
editor = ENV.fetch('EDITOR', 'vi')
|
|
29
|
+
system("#{editor} #{file}")
|
|
30
|
+
Caddy.validate!(Paths.caddyfile)
|
|
31
|
+
Caddy.reload(Paths.caddyfile) if site.enabled
|
|
32
|
+
puts " Saved and reloaded."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|