diff options
| author | Claude <noreply@anthropic.com> | 2026-09-02 21:00:08 -0500 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-02 21:00:08 -0500 |
| commit | 6040eff2d2b4618f19d4615d246ea6b0704acdf8 (patch) | |
| tree | 1937c2607a5e980e2d9db4c0df7515a699f0c93a /cmd | |
| parent | 025a365a23f5ed2b3df4df71cbff9689575968d9 (diff) | |
Add domain and TLS management via skiff domains add/remove/list
Replaces the single server_name string in meta.json with a domain list
(one primary, others redirect to it) and TLSDomains tracking what the
app's certificate actually covers. Certs are issued/expanded via certbot
certonly (webroot, never the --nginx plugin, since Skiff owns and
regenerates skiff.d/*.conf itself) as one SAN cert per app, matching how
a real multi-domain vhost is normally issued.
Domain/TLS changes never touch the jail - no restart, no health check -
since they only affect nginx and the certificate. `add` is safe to retry
if DNS isn't ready yet; `remove` never calls certbot at all, so it can't
fail on an ACME/network problem.
Also updates README/USER_GUIDE for the new command surface, one-time TLS
host setup (certbot register, renewal cron, reload hook), the --ip
same-subnet requirement for jails to reach the network at build time, and
the actual base-image provisioning sequence (pkg needs an explicit
bootstrap after bsdinstall; versioned port names like go125 need a
default symlink) - all found and fixed live while getting this working
end-to-end on a real FreeBSD VM.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/skiff/main.go | 116 |
1 files changed, 109 insertions, 7 deletions
diff --git a/cmd/skiff/main.go b/cmd/skiff/main.go index 3286da7..cc48c67 100644 --- a/cmd/skiff/main.go +++ b/cmd/skiff/main.go @@ -14,6 +14,7 @@ import ( "skiff/internal/appmeta" "skiff/internal/deploy" + "skiff/internal/domains" "skiff/internal/envfile" "skiff/internal/jailmgr" "skiff/internal/layout" @@ -26,7 +27,7 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: skiff <deploy|new-app|rollback|rebuild|config> [flags]") + fmt.Fprintln(os.Stderr, "usage: skiff <deploy|new-app|rollback|rebuild|config|domains> [flags]") os.Exit(1) } @@ -42,6 +43,8 @@ func main() { err = cmdRebuild(os.Args[2:]) case "config": err = cmdConfig(os.Args[2:]) + case "domains": + err = cmdDomains(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) os.Exit(1) @@ -86,7 +89,6 @@ func cmdNewApp(args []string) error { ip := fs.String("ip", "", "IPv4 address to assign the jail (operator-chosen)") port := fs.Int("port", layout.DefaultHealthPort, "port the app listens on inside the jail") healthPath := fs.String("health-path", layout.DefaultHealthPath, "health check path") - serverName := fs.String("server-name", "", "nginx server_name (defaults to <name>)") baseSnapshot := fs.String("base-snapshot", "ready", "snapshot on the base dataset to clone from") iface := fs.String("iface", layout.DefaultJailIface, "host interface the jail's IP is aliased onto") fs.Parse(args) @@ -94,9 +96,6 @@ func cmdNewApp(args []string) error { if *name == "" || *ip == "" { return fmt.Errorf("--name and --ip are required") } - if *serverName == "" { - *serverName = *name - } ctx := context.Background() out := os.Stdout @@ -136,7 +135,6 @@ func cmdNewApp(args []string) error { IP: *ip, Port: *port, HealthPath: *healthPath, - ServerName: *serverName, }); err != nil { return fmt.Errorf("write app metadata: %w", err) } @@ -158,7 +156,7 @@ func cmdNewApp(args []string) error { return fmt.Errorf("start jail: %w", err) } - fmt.Fprintf(out, "\n%s is ready. From the app's repo:\n git remote add skiff <this-host>:%s\n git push skiff main\n", *name, cfg.RepoPath(*name)) + fmt.Fprintf(out, "\n%s is ready. From the app's repo:\n git remote add skiff <this-host>:%s\n git push skiff main\n\nNo domains are routed yet - once DNS points at %s:\n skiff domains add --app %s <hostname>\n", *name, cfg.RepoPath(*name), *ip, *name) return nil } @@ -396,3 +394,107 @@ func applyConfigChange(app string, mutate func(map[string]string) error) error { fmt.Fprintln(out, "==> config updated") return nil } + +func cmdDomains(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: skiff domains <add|remove|list> --app <name> [args...]") + } + switch args[0] { + case "add": + return cmdDomainsAdd(args[1:]) + case "remove": + return cmdDomainsRemove(args[1:]) + case "list": + return cmdDomainsList(args[1:]) + default: + return fmt.Errorf("unknown domains subcommand %q", args[0]) + } +} + +// Unlike cmdConfigSet, these skip deploy.GuardedApply - see internal/domains. +func cmdDomainsAdd(args []string) error { + fs := flag.NewFlagSet("domains add", flag.ExitOnError) + app := fs.String("app", "", "app name") + primary := fs.Bool("primary", false, "make this the app's primary domain - every other domain redirects to it") + fs.Parse(args) + rest := fs.Args() + + if *app == "" || len(rest) != 1 { + return fmt.Errorf("usage: skiff domains add --app <name> [--primary] <hostname>") + } + + cfg := layout.Default() + if _, err := os.Stat(cfg.MetaPath(*app)); err != nil { + return fmt.Errorf("app %q not found: %w", *app, err) + } + + unlock, err := lock.Acquire(cfg.LockPath(*app)) + if err != nil { + return fmt.Errorf("acquire app lock: %w", err) + } + defer unlock.Release() + + return domains.Add(context.Background(), os.Stdout, cfg, *app, rest[0], *primary) +} + +func cmdDomainsRemove(args []string) error { + fs := flag.NewFlagSet("domains remove", flag.ExitOnError) + app := fs.String("app", "", "app name") + fs.Parse(args) + rest := fs.Args() + + if *app == "" || len(rest) != 1 { + return fmt.Errorf("usage: skiff domains remove --app <name> <hostname>") + } + + cfg := layout.Default() + if _, err := os.Stat(cfg.MetaPath(*app)); err != nil { + return fmt.Errorf("app %q not found: %w", *app, err) + } + + unlock, err := lock.Acquire(cfg.LockPath(*app)) + if err != nil { + return fmt.Errorf("acquire app lock: %w", err) + } + defer unlock.Release() + + return domains.Remove(context.Background(), os.Stdout, cfg, *app, rest[0]) +} + +func cmdDomainsList(args []string) error { + fs := flag.NewFlagSet("domains list", flag.ExitOnError) + app := fs.String("app", "", "app name") + fs.Parse(args) + + if *app == "" { + return fmt.Errorf("--app is required") + } + + cfg := layout.Default() + meta, err := appmeta.Read(cfg.MetaPath(*app)) + if err != nil { + return fmt.Errorf("app %q not found: %w", *app, err) + } + if len(meta.Domains) == 0 { + fmt.Println("no domains configured") + return nil + } + + tlsLive := make(map[string]bool, len(meta.TLSDomains)) + for _, h := range meta.TLSDomains { + tlsLive[h] = true + } + + fmt.Printf("%-30s %-8s %s\n", "HOSTNAME", "PRIMARY", "TLS") + for _, d := range meta.Domains { + primaryCol, tlsCol := "no", "pending" + if d.Primary { + primaryCol = "yes" + } + if tlsLive[d.Hostname] { + tlsCol = "issued" + } + fmt.Printf("%-30s %-8s %s\n", d.Hostname, primaryCol, tlsCol) + } + return nil +} |
