diff --git a/.github/wiki/Config-Nginx.md b/.github/wiki/Config-Nginx.md index 0f4b9439c..255e03dc1 100644 --- a/.github/wiki/Config-Nginx.md +++ b/.github/wiki/Config-Nginx.md @@ -201,6 +201,8 @@ NGINX_FRONTED_BY=nself-web With this set, `nself build` omits the Nginx service from the generated `docker-compose.yml` entirely, and `nself status` stops counting an Nginx container for this project. Leaving it unset is the default and changes nothing for a normal single-project stack. +The fronting stack's Nginx reads certificates from its own `ssl/` directory. A fronted project therefore gets HTTPS server blocks (and plugin snippets that reference a certificate) only when `ssl/certificates//fullchain.pem` and `privkey.pem` exist in the fronting stack's `ssl/`, whatever `SSL_MODE` is (`none` still turns TLS off). Without the pair there, the build writes HTTP-only confs and skips TLS plugin snippets with a warning, instead of generating a conf that Nginx cannot load. Under `NGINX_MODE=shared` the build copies the project's pair into the owner's `ssl/` itself, so that rule does not apply. + Without it, a fronted project still generates an Nginx that can never start: `nself start` fails its port check with 80/443 held by the fronting stack's `docker-proxy`, and `nself status` reports one permanently unhealthy service that nothing can clear. **This flag removes the container that cannot run. It does not wire the two projects' networks together.** The fronting Nginx resolves upstreams through Docker's embedded DNS at `127.0.0.11`, which only answers for container names on a network that Nginx is attached to. Routing `auth.task.example.com` to this project's `auth` container still requires the two projects to share a Docker network — declared as `external` in both compose files, or attached by hand. Automatically rewriting a project's network topology from a single flag is a larger change and is deliberately not part of this one. diff --git a/internal/nginx/ssl_detect.go b/internal/nginx/ssl_detect.go index 448285132..a674a4fec 100644 --- a/internal/nginx/ssl_detect.go +++ b/internal/nginx/ssl_detect.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/nginxtopo" "github.com/nself-org/cli/internal/ssl" ) @@ -42,6 +43,14 @@ import ( // - any other mode ("letsencrypt", "custom", ...) checks the filesystem: // these modes never generate certs themselves, so presence on disk is // the only trustworthy signal that nginx can safely reference them. +// - a fronted project (NGINX_FRONTED_BY) is served by the fronting stack's +// nginx, which mounts its own ssl/ (D-0572): for every mode but "none" the +// pair must be present there, so neither site confs nor plugin snippets +// name a certificate the serving nginx cannot load. The project's own +// ssl/ (written by this build) and assumed plan-mode pairs do not count. +// Exception: under a shared proxy (NGINX_MODE=shared owner) the build +// copies the project's pair into the owner's ssl/ itself +// (internal/build/shared_proxy_certs.go), so the rules below apply. // - assumed lists domains whose certificate pair a plan-mode build would // create but has not (WithAssumedCerts): a pair for cfg.BaseDomain among // them counts as present, so a plan keeps the TLS blocks apply produces. @@ -49,6 +58,11 @@ func sslShouldEmit(cfg *config.Config, workdir, mode string, assumed []string) b switch mode { case "none": return false + } + if cfg.Nginx.FrontedBy != "" && !sharedProxyFronted(cfg, workdir) { + return servedCertsPresent(cfg, workdir) + } + switch mode { case "local": return true default: @@ -87,7 +101,32 @@ func sslDirName(domain string) string { } func sslCertsPresent(cfg *config.Config, workdir string) bool { - certDir := filepath.Join(workdir, "ssl", "certificates", sslDirName(cfg.BaseDomain)) + return certPairPresent(filepath.Join(workdir, "ssl"), cfg.BaseDomain) +} + +// servedCertsPresent is sslCertsPresent against the ssl dir the fronting +// stack's nginx mounts (nginxtopo.ServedSSLDir). An unconfirmed fronting +// layout reads as absent: the build refuses it anyway (resolveNginxSitesDir). +func servedCertsPresent(cfg *config.Config, workdir string) bool { + dir, err := nginxtopo.ServedSSLDir(workdir, cfg.Nginx.FrontedBy) + return err == nil && certPairPresent(dir, cfg.BaseDomain) +} + +// sharedProxyFronted reports whether the fronting stack is a shared-proxy +// owner, whose registration makes the build copy this project's pair into the +// owner's ssl/. +func sharedProxyFronted(cfg *config.Config, workdir string) bool { + root, err := nginxtopo.ServedRoot(workdir, cfg.Nginx.FrontedBy) + if err != nil { + return false + } + return nginxtopo.SharedOwnerRegistered(root) +} + +// certPairPresent reports whether fullchain.pem and privkey.pem exist under +// /certificates/. +func certPairPresent(sslDir, domain string) bool { + certDir := filepath.Join(sslDir, "certificates", sslDirName(domain)) if _, err := os.Stat(filepath.Join(certDir, "fullchain.pem")); err != nil { return false } diff --git a/internal/nginx/ssl_detect_fronted_test.go b/internal/nginx/ssl_detect_fronted_test.go new file mode 100644 index 000000000..0d5efe82d --- /dev/null +++ b/internal/nginx/ssl_detect_fronted_test.go @@ -0,0 +1,80 @@ +package nginx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nself-org/cli/internal/nginxtopo" +) + +// ssl_detect_fronted_test.go — D-0572: a fronted project (NGINX_FRONTED_BY) is +// served by the fronting stack's nginx, so TLS blocks follow the certificate +// pair in the fronting stack's ssl/, not the project's own. + +// frontedLayout returns /nself-web/backend (the project) and the +// fronting stack dir /nself-web. +func frontedLayout(t *testing.T) (project, stack string) { + t.Helper() + stack = filepath.Join(t.TempDir(), "nself-web") + project = filepath.Join(stack, "backend") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + return project, stack +} + +func TestSSLShouldEmitFronted(t *testing.T) { + cases := []struct { + name string + mode string + ownPair bool // pair in the project's own ssl/ + servedPair bool // pair in the fronting stack's ssl/ + frontedBy string + assumedOwn bool + sharedOwner bool // the fronting stack is a shared-proxy owner + want bool + }{ + {name: "local, only own pair", mode: "local", ownPair: true, frontedBy: "nself-web", want: false}, + {name: "local, served pair", mode: "local", servedPair: true, frontedBy: "nself-web", want: true}, + {name: "letsencrypt, only own pair", mode: "letsencrypt", ownPair: true, frontedBy: "nself-web", want: false}, + {name: "letsencrypt, served pair", mode: "letsencrypt", servedPair: true, frontedBy: "nself-web", want: true}, + {name: "custom, assumed plan pair does not count", mode: "custom", frontedBy: "nself-web", assumedOwn: true, want: false}, + {name: "none stays off with served pair", mode: "none", servedPair: true, frontedBy: "nself-web", want: false}, + {name: "unconfirmed layout reads as absent", mode: "local", ownPair: true, frontedBy: "other-stack", want: false}, + {name: "shared owner, local keeps the copy-in flow", mode: "local", frontedBy: "nself-web", sharedOwner: true, want: true}, + {name: "shared owner, letsencrypt with own pair", mode: "letsencrypt", ownPair: true, frontedBy: "nself-web", sharedOwner: true, want: true}, + {name: "shared owner, letsencrypt without pair", mode: "letsencrypt", frontedBy: "nself-web", sharedOwner: true, want: false}, + {name: "unfronted local is unchanged", mode: "local", frontedBy: "", want: true}, + {name: "unfronted letsencrypt with own pair is unchanged", mode: "letsencrypt", ownPair: true, frontedBy: "", want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + project, stack := frontedLayout(t) + cfg := newCfgForSSLDetect(t, c.mode) + cfg.Nginx.FrontedBy = c.frontedBy + if c.ownPair { + writeCertPair(t, project, cfg.BaseDomain) + } + if c.servedPair { + writeCertPair(t, stack, cfg.BaseDomain) + } + if c.sharedOwner { + dir := filepath.Join(stack, "nginx", nginxtopo.SharedDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, nginxtopo.SharedOwnerFile), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + var assumed []string + if c.assumedOwn { + assumed = []string{cfg.BaseDomain} + } + if got := sslShouldEmit(cfg, project, c.mode, assumed); got != c.want { + t.Fatalf("sslShouldEmit = %v, want %v", got, c.want) + } + }) + } +} diff --git a/internal/nginxtopo/served.go b/internal/nginxtopo/served.go index 0f477d837..fd35f225c 100644 --- a/internal/nginxtopo/served.go +++ b/internal/nginxtopo/served.go @@ -22,6 +22,7 @@ package nginxtopo import ( "errors" "fmt" + "os" "path/filepath" ) @@ -73,3 +74,20 @@ func ServedSSLDir(projectDir, frontedBy string) (string, error) { } return filepath.Join(root, "ssl"), nil } + +// SharedDir and SharedOwnerFile name the shared-proxy registration directory +// (/nginx/shared) and the file that marks a stack as a shared-proxy +// owner. internal/sharedproxy re-exports them; they live here so lower layers +// can ask the question without importing it. +const ( + SharedDir = "shared" + SharedOwnerFile = "_owner.json" +) + +// SharedOwnerRegistered reports whether the stack rooted at servedRoot is a +// shared-proxy owner: its nginx/shared/_owner.json exists. +func SharedOwnerRegistered(servedRoot string) bool { + nginxDir, _ := ServedNginxDir(servedRoot, "") // cannot fail with an empty frontedBy + _, err := os.Stat(filepath.Join(nginxDir, SharedDir, SharedOwnerFile)) + return err == nil +} diff --git a/internal/sharedproxy/registration.go b/internal/sharedproxy/registration.go index 8a16a5f9a..5c2db7541 100644 --- a/internal/sharedproxy/registration.go +++ b/internal/sharedproxy/registration.go @@ -31,8 +31,8 @@ import ( const ( Generated = "GENERATED BY nself build - DO NOT HAND EDIT" SchemaVersion = "1" - OwnerFile = "_owner.json" - Dir = "shared" + OwnerFile = nginxtopo.SharedOwnerFile + Dir = nginxtopo.SharedDir ) // Registration is one project's entry. Field order is the key order of the