From 6040eff2d2b4618f19d4615d246ea6b0704acdf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:00:08 -0500 Subject: 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 --- cmd/skiff/main.go | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 7 deletions(-) (limited to 'cmd') 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 [flags]") + fmt.Fprintln(os.Stderr, "usage: skiff [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 )") 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 :%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 :%s\n git push skiff main\n\nNo domains are routed yet - once DNS points at %s:\n skiff domains add --app %s \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 --app [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 [--primary] ") + } + + 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 ") + } + + 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 +} -- cgit v1.3