summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-02 21:00:08 -0500
committerClaude <noreply@anthropic.com>2026-09-02 21:00:08 -0500
commit6040eff2d2b4618f19d4615d246ea6b0704acdf8 (patch)
tree1937c2607a5e980e2d9db4c0df7515a699f0c93a /internal
parent025a365a23f5ed2b3df4df71cbff9689575968d9 (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 'internal')
-rw-r--r--internal/appmeta/appmeta.go33
-rw-r--r--internal/certbot/certbot.go26
-rw-r--r--internal/deploy/deploy.go2
-rw-r--r--internal/domains/domains.go128
-rw-r--r--internal/layout/layout.go13
-rw-r--r--internal/nginx/nginx.go53
-rw-r--r--internal/tmpl/nginx-app.conf.tmpl38
-rw-r--r--internal/tmpl/tmpl.go21
8 files changed, 293 insertions, 21 deletions
diff --git a/internal/appmeta/appmeta.go b/internal/appmeta/appmeta.go
index 61ceb18..72c8afc 100644
--- a/internal/appmeta/appmeta.go
+++ b/internal/appmeta/appmeta.go
@@ -1,7 +1,7 @@
// Package appmeta stores the small amount of per-app networking config
-// Skiff itself needs (IP, port, health check) that isn't already implied by
-// jail.conf/rc.conf - a flat file living with the app's own data, not a
-// separate control-plane database.
+// Skiff itself needs (IP, port, health check, domains) that isn't already
+// implied by jail.conf/rc.conf - a flat file living with the app's own
+// data, not a separate control-plane database.
package appmeta
import (
@@ -9,11 +9,30 @@ import (
"os"
)
+// Domain is one hostname routed to the app; every non-Primary one redirects
+// to whichever is Primary.
+type Domain struct {
+ Hostname string `json:"hostname"`
+ Primary bool `json:"primary"`
+}
+
type Meta struct {
- IP string `json:"ip"`
- Port int `json:"port"`
- HealthPath string `json:"health_path"`
- ServerName string `json:"server_name"`
+ IP string `json:"ip"`
+ Port int `json:"port"`
+ HealthPath string `json:"health_path"`
+ Domains []Domain `json:"domains,omitempty"`
+ // Hostnames the app's cert actually covers as of the last sync - may
+ // lag Domains after an add (not yet issued) or a remove (not pruned).
+ TLSDomains []string `json:"tls_domains,omitempty"`
+}
+
+func (m Meta) PrimaryDomain() (Domain, bool) {
+ for _, d := range m.Domains {
+ if d.Primary {
+ return d, true
+ }
+ }
+ return Domain{}, false
}
func Write(path string, m Meta) error {
diff --git a/internal/certbot/certbot.go b/internal/certbot/certbot.go
new file mode 100644
index 0000000..3ce5b41
--- /dev/null
+++ b/internal/certbot/certbot.go
@@ -0,0 +1,26 @@
+// Package certbot issues Let's Encrypt certificates via HTTP-01 webroot
+// validation. Deliberately not the --nginx installer plugin - Skiff, not
+// certbot, owns and regenerates skiff.d/*.conf.
+package certbot
+
+import (
+ "context"
+ "io"
+
+ "skiff/internal/sh"
+)
+
+// Sync issues or expands the certificate named certName to cover exactly
+// domains, via challenge files served from webroot. Idempotent. Assumes
+// `certbot register` has already been run once on the host.
+func Sync(ctx context.Context, out io.Writer, webroot, certName string, domains []string) error {
+ args := []string{
+ "certonly", "--webroot", "-w", webroot,
+ "--cert-name", certName, "--expand",
+ "--non-interactive", "--agree-tos", "--keep-until-expiring",
+ }
+ for _, d := range domains {
+ args = append(args, "-d", d)
+ }
+ return sh.Run(ctx, out, "", "certbot", args...)
+}
diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go
index 8d32613..736b2e1 100644
--- a/internal/deploy/deploy.go
+++ b/internal/deploy/deploy.go
@@ -98,7 +98,7 @@ func Run(ctx context.Context, out io.Writer, cfg layout.Config, app, sha string)
if err != nil {
return fmt.Errorf("read app metadata: %w", err)
}
- if err := nginx.EnsureRoute(ctx, out, cfg, app, meta.IP, meta.Port, meta.ServerName); err != nil {
+ if err := nginx.EnsureRoute(ctx, out, cfg, app, meta); err != nil {
return fmt.Errorf("nginx: %w", err)
}
diff --git a/internal/domains/domains.go b/internal/domains/domains.go
new file mode 100644
index 0000000..5bf264f
--- /dev/null
+++ b/internal/domains/domains.go
@@ -0,0 +1,128 @@
+// Package domains manages an app's public hostnames and TLS. Unlike other
+// app config, a domain/cert change never touches the jail, so it skips
+// Skiff's usual snapshot-and-restart safety net entirely.
+package domains
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "skiff/internal/appmeta"
+ "skiff/internal/certbot"
+ "skiff/internal/layout"
+ "skiff/internal/nginx"
+)
+
+// Add registers hostname for app and syncs routing/TLS. Safe to retry: a
+// DNS-not-ready cert failure just leaves the app on plain HTTP.
+func Add(ctx context.Context, out io.Writer, cfg layout.Config, app, hostname string, primary bool) error {
+ meta, err := appmeta.Read(cfg.MetaPath(app))
+ if err != nil {
+ return fmt.Errorf("read app metadata: %w", err)
+ }
+
+ makePrimary := primary || len(meta.Domains) == 0
+ found := false
+ for i := range meta.Domains {
+ if meta.Domains[i].Hostname == hostname {
+ found = true
+ break
+ }
+ }
+ if makePrimary {
+ for i := range meta.Domains {
+ meta.Domains[i].Primary = meta.Domains[i].Hostname == hostname
+ }
+ }
+ if !found {
+ meta.Domains = append(meta.Domains, appmeta.Domain{Hostname: hostname, Primary: makePrimary})
+ }
+
+ if err := appmeta.Write(cfg.MetaPath(app), meta); err != nil {
+ return fmt.Errorf("write app metadata: %w", err)
+ }
+
+ if err := syncCert(ctx, out, cfg, app, meta); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "==> %s added for %s\n", hostname, app)
+ return nil
+}
+
+// Remove deregisters hostname and updates routing only - unlike Add, it
+// never calls certbot, so it can't fail or block on it. Removing the
+// primary while others remain is refused - reassign primary first with
+// `skiff domains add --primary`.
+func Remove(ctx context.Context, out io.Writer, cfg layout.Config, app, hostname string) error {
+ meta, err := appmeta.Read(cfg.MetaPath(app))
+ if err != nil {
+ return fmt.Errorf("read app metadata: %w", err)
+ }
+
+ idx := -1
+ for i, d := range meta.Domains {
+ if d.Hostname == hostname {
+ idx = i
+ break
+ }
+ }
+ if idx == -1 {
+ return fmt.Errorf("%s is not registered for %s", hostname, app)
+ }
+ if meta.Domains[idx].Primary && len(meta.Domains) > 1 {
+ return fmt.Errorf("%s is %s's primary domain - reassign primary to another domain first (skiff domains add --primary <hostname>)", hostname, app)
+ }
+ meta.Domains = append(meta.Domains[:idx], meta.Domains[idx+1:]...)
+
+ if err := appmeta.Write(cfg.MetaPath(app), meta); err != nil {
+ return fmt.Errorf("write app metadata: %w", err)
+ }
+
+ if len(meta.Domains) == 0 {
+ if err := nginx.Remove(ctx, out, cfg, app); err != nil {
+ return err
+ }
+ } else if err := nginx.EnsureRoute(ctx, out, cfg, app, meta); err != nil {
+ return fmt.Errorf("nginx: %w", err)
+ }
+
+ fmt.Fprintf(out, "==> %s removed from %s\n", hostname, app)
+ return nil
+}
+
+// syncCert routes over plain HTTP first, so a newly added hostname is
+// reachable for the HTTP-01 challenge, then requests/expands the cert and
+// re-routes with TLS enabled for whatever it now covers.
+func syncCert(ctx context.Context, out io.Writer, cfg layout.Config, app string, meta appmeta.Meta) error {
+ primary, ok := meta.PrimaryDomain()
+ if !ok {
+ return fmt.Errorf("%s has domains but none marked primary", app)
+ }
+
+ if err := nginx.EnsureRoute(ctx, out, cfg, app, meta); err != nil {
+ return fmt.Errorf("nginx: %w", err)
+ }
+
+ if err := os.MkdirAll(cfg.AcmeWebrootDir, 0755); err != nil {
+ return fmt.Errorf("create acme webroot: %w", err)
+ }
+
+ hostnames := make([]string, len(meta.Domains))
+ for i, d := range meta.Domains {
+ hostnames[i] = d.Hostname
+ }
+
+ fmt.Fprintln(out, "==> requesting certificate")
+ if err := certbot.Sync(ctx, out, cfg.AcmeWebrootDir, primary.Hostname, hostnames); err != nil {
+ return fmt.Errorf("certificate not issued (%w) - fix DNS and re-run this command", err)
+ }
+
+ meta.TLSDomains = hostnames
+ if err := appmeta.Write(cfg.MetaPath(app), meta); err != nil {
+ return fmt.Errorf("write app metadata: %w", err)
+ }
+
+ return nginx.EnsureRoute(ctx, out, cfg, app, meta)
+}
diff --git a/internal/layout/layout.go b/internal/layout/layout.go
index 5ac6c26..f350efb 100644
--- a/internal/layout/layout.go
+++ b/internal/layout/layout.go
@@ -24,6 +24,9 @@ type Config struct {
JailConfDir string // /etc/jail.conf.d
NginxConfDir string // nginx include dir regenerated on every deploy
LocksDir string // per-app flock files live here, outside any per-app dataset
+
+ AcmeWebrootDir string // static dir nginx serves ACME HTTP-01 challenge files from, shared by every app
+ LetsEncryptDir string // certbot's own state dir
}
func Default() Config {
@@ -35,6 +38,9 @@ func Default() Config {
JailConfDir: "/etc/jail.conf.d",
NginxConfDir: "/usr/local/etc/nginx/skiff.d",
LocksDir: "/skiff/locks",
+
+ AcmeWebrootDir: "/skiff/acme-challenge",
+ LetsEncryptDir: "/usr/local/etc/letsencrypt",
}
}
@@ -78,6 +84,13 @@ func (c Config) NginxConfPath(app string) string {
return filepath.Join(c.NginxConfDir, app+".conf")
}
+// LetsEncryptLiveDir is where certbot writes the live cert/key for a
+// certificate named certName (Skiff always names one after its app's
+// primary domain).
+func (c Config) LetsEncryptLiveDir(certName string) string {
+ return filepath.Join(c.LetsEncryptDir, "live", certName)
+}
+
func (c Config) PostReceiveHookPath(app string) string {
return filepath.Join(c.RepoPath(app), "hooks", "post-receive")
}
diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go
index abdd4c9..4a85435 100644
--- a/internal/nginx/nginx.go
+++ b/internal/nginx/nginx.go
@@ -9,19 +9,44 @@ import (
"io"
"os"
+ "skiff/internal/appmeta"
"skiff/internal/layout"
"skiff/internal/sh"
"skiff/internal/tmpl"
)
-// EnsureRoute writes app's vhost file and reloads nginx, but only if the
-// rendered config actually changed - most deploys don't touch routing.
-func EnsureRoute(ctx context.Context, out io.Writer, cfg layout.Config, app, ip string, port int, serverName string) error {
+// EnsureRoute (re)writes app's vhost file from meta and reloads nginx, but
+// only if the rendered config changed. A hostname gets HTTPS only once it's
+// in both meta.Domains and meta.TLSDomains. No domains yet is a no-op.
+func EnsureRoute(ctx context.Context, out io.Writer, cfg layout.Config, app string, meta appmeta.Meta) error {
+ if len(meta.Domains) == 0 {
+ fmt.Fprintf(out, "==> no domains configured for %s, skipping nginx route\n", app)
+ return nil
+ }
+ primary, ok := meta.PrimaryDomain()
+ if !ok {
+ return fmt.Errorf("%s has domains but none marked primary", app)
+ }
+
+ live := make(map[string]bool, len(meta.TLSDomains))
+ for _, h := range meta.TLSDomains {
+ live[h] = true
+ }
+
+ doms := make([]tmpl.NginxDomain, len(meta.Domains))
+ for i, d := range meta.Domains {
+ doms[i] = tmpl.NginxDomain{Hostname: d.Hostname, Primary: d.Primary, CertLive: live[d.Hostname]}
+ }
+
rendered, err := tmpl.NginxApp(tmpl.NginxAppData{
- Name: app,
- IP: ip,
- Port: port,
- ServerName: serverName,
+ Name: app,
+ IP: meta.IP,
+ Port: meta.Port,
+ Webroot: cfg.AcmeWebrootDir,
+ PrimaryHostname: primary.Hostname,
+ PrimaryCertLive: live[primary.Hostname],
+ PrimaryLiveDir: cfg.LetsEncryptLiveDir(primary.Hostname),
+ Domains: doms,
})
if err != nil {
return err
@@ -40,6 +65,20 @@ func EnsureRoute(ctx context.Context, out io.Writer, cfg layout.Config, app, ip
return Reload(ctx, out)
}
+// Remove deletes app's vhost file entirely and reloads - used once its last
+// domain is removed, since there's nothing left to route.
+func Remove(ctx context.Context, out io.Writer, cfg layout.Config, app string) error {
+ path := cfg.NginxConfPath(app)
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("remove nginx conf for %s: %w", app, err)
+ }
+ fmt.Fprintf(out, "==> nginx route removed for %s, reloading\n", app)
+ return Reload(ctx, out)
+}
+
func Reload(ctx context.Context, out io.Writer) error {
return sh.Run(ctx, out, "", "nginx", "-s", "reload")
}
diff --git a/internal/tmpl/nginx-app.conf.tmpl b/internal/tmpl/nginx-app.conf.tmpl
index b55a332..66ce0d5 100644
--- a/internal/tmpl/nginx-app.conf.tmpl
+++ b/internal/tmpl/nginx-app.conf.tmpl
@@ -2,14 +2,48 @@ upstream {{.Name}}_upstream {
server {{.IP}}:{{.Port}};
}
+{{range .Domains}}
server {
listen 80;
- server_name {{.ServerName}};
+ server_name {{.Hostname}};
+
+ location ^~ /.well-known/acme-challenge/ {
+ root {{$.Webroot}};
+ }
+
+ location / {
+{{if .CertLive}}
+ return 301 https://{{$.PrimaryHostname}}$request_uri;
+{{else if .Primary}}
+ proxy_pass http://{{$.Name}}_upstream;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+{{else if $.PrimaryCertLive}}
+ return 301 https://{{$.PrimaryHostname}}$request_uri;
+{{else}}
+ return 301 http://{{$.PrimaryHostname}}$request_uri;
+{{end}}
+ }
+}
+{{if .CertLive}}
+server {
+ listen 443 ssl;
+ server_name {{.Hostname}};
+
+ ssl_certificate {{$.PrimaryLiveDir}}/fullchain.pem;
+ ssl_certificate_key {{$.PrimaryLiveDir}}/privkey.pem;
location / {
- proxy_pass http://{{.Name}}_upstream;
+{{if .Primary}}
+ proxy_pass http://{{$.Name}}_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+{{else}}
+ return 301 https://{{$.PrimaryHostname}}$request_uri;
+{{end}}
}
}
+{{end}}
+{{end}}
diff --git a/internal/tmpl/tmpl.go b/internal/tmpl/tmpl.go
index 12baa70..0213919 100644
--- a/internal/tmpl/tmpl.go
+++ b/internal/tmpl/tmpl.go
@@ -46,11 +46,24 @@ type RCData struct{ Name string }
func RCScript(d RCData) (string, error) { return Render(rcScript, d) }
+// NginxDomain is one hostname in an app's vhost. CertLive gates HTTPS.
+type NginxDomain struct {
+ Hostname string
+ Primary bool
+ CertLive bool
+}
+
type NginxAppData struct {
- Name string
- IP string
- Port int
- ServerName string
+ Name string
+ IP string
+ Port int
+ Webroot string // ACME HTTP-01 challenge webroot, shared across apps
+
+ PrimaryHostname string
+ PrimaryCertLive bool
+ PrimaryLiveDir string // certbot's live dir for the app's shared cert
+
+ Domains []NginxDomain
}
func NginxApp(d NginxAppData) (string, error) { return Render(nginxApp, d) }