Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/wiki/Config-Nginx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>/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.
Expand Down
41 changes: 40 additions & 1 deletion internal/nginx/ssl_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -42,13 +43,26 @@ 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.
func sslShouldEmit(cfg *config.Config, workdir, mode string, assumed []string) bool {
switch mode {
case "none":
return false
}
if cfg.Nginx.FrontedBy != "" && !sharedProxyFronted(cfg, workdir) {
return servedCertsPresent(cfg, workdir)
}
switch mode {
case "local":
return true
default:
Expand Down Expand Up @@ -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
// <sslDir>/certificates/<dir of domain>.
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
}
Expand Down
80 changes: 80 additions & 0 deletions internal/nginx/ssl_detect_fronted_test.go
Original file line number Diff line number Diff line change
@@ -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 <root>/nself-web/backend (the project) and the
// fronting stack dir <root>/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)
}
})
}
}
18 changes: 18 additions & 0 deletions internal/nginxtopo/served.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package nginxtopo
import (
"errors"
"fmt"
"os"
"path/filepath"
)

Expand Down Expand Up @@ -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
// (<served root>/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
}
4 changes: 2 additions & 2 deletions internal/sharedproxy/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading