diff --git a/examples/modules/gitea.json b/examples/modules/gitea.json index 9a329e9..963eda2 100644 --- a/examples/modules/gitea.json +++ b/examples/modules/gitea.json @@ -7,7 +7,7 @@ "postgres-database": {"name": "gitea"} }, "binds": {"postgres-database": "/var/lib/gitea/database.json"}, - "secrets": {"postgres-database": "/var/lib/gitea/database.env"}, + "secrets": {"postgres-database": "/var/lib/gitea/database.secret"}, "capabilities": ["container-runtime"], @@ -17,14 +17,20 @@ "why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it"} ], + "own-secrets": {"internal-token": "/var/lib/gitea/internal-token.secret"}, + "resources": [ {"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"}, + {"id": "server-env", "type": "file", "path": "/var/lib/gitea/server.env", "mode": "0600", + "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n"}, + {"id": "server", "type": "container", "name": "gitea", "image": "gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", "env": {"DB_TYPE": "postgres", "USER_UID": "1000", "USER_GID": "1000"}, - "env-file": ["/var/lib/gitea/database.env"], + "env-file": ["/var/lib/gitea/server.env"], "ports": ["3000:3000", "2222:22"], - "volumes": ["/services/gitea/gitea:/data"]} + "volumes": ["/services/gitea/gitea:/data"], + "restart-on": ["server-env"]} ] } diff --git a/examples/modules/keycloak.json b/examples/modules/keycloak.json index 28aa76b..5b7af87 100644 --- a/examples/modules/keycloak.json +++ b/examples/modules/keycloak.json @@ -7,7 +7,7 @@ "postgres-database": {"name": "keycloak"} }, "binds": {"postgres-database": "/var/lib/keycloak/database.json"}, - "secrets": {"postgres-database": "/var/lib/keycloak/database.env"}, + "secrets": {"postgres-database": "/var/lib/keycloak/database.secret"}, "provides": [{"name": "oidc-client", "scope": "mesh"}], "capabilities": ["container-runtime"], @@ -24,12 +24,15 @@ "receives": {"oidc-client": "/var/lib/keycloak/grants/mesh.json"}, "grants": {"oidc-client": "/var/lib/keycloak/grants"}, - "own-secrets": {"admin": "/var/lib/keycloak/admin.env"}, + "own-secrets": {"admin": "/var/lib/keycloak/admin.secret"}, "resources": [ {"id": "state", "type": "directory", "path": "/var/lib/keycloak", "mode": "0700"}, {"id": "grants", "type": "directory", "path": "/var/lib/keycloak/grants", "mode": "0700"}, + {"id": "admin-env", "type": "file", "path": "/var/lib/keycloak/admin.env", "mode": "0600", + "content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n"}, + {"id": "net", "type": "network", "name": "keycloak"}, {"id": "server", "type": "container", "name": "keycloak", @@ -37,8 +40,9 @@ "network": "keycloak", "args": ["start-dev"], "env": {"KC_DB": "postgres", "KC_HTTP_ENABLED": "true", "KC_HEALTH_ENABLED": "true"}, - "env-file": ["/var/lib/keycloak/admin.env", "/var/lib/keycloak/database.env"], - "ports": ["8080:8080"]}, + "env-file": ["/var/lib/keycloak/admin.env"], + "ports": ["8080:8080"], + "restart-on": ["admin-env"]}, {"id": "provisioner", "type": "container", "name": "mesh-provision-keycloak", "image": "mesh-provision-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", @@ -46,10 +50,14 @@ "env": { "GRANTS": "/var/lib/keycloak/grants", "MESH_KEYCLOAK_URL": "http://keycloak:8080", - "MESH_KEYCLOAK_REALM": "mesh" + "MESH_KEYCLOAK_REALM": "mesh", + "MESH_KEYCLOAK_ADMIN": "admin", + "MESH_KEYCLOAK_ADMIN_PASSWORD_FILE": "/run/secrets/admin" }, - "env-file": ["/var/lib/keycloak/admin.env"], - "volumes": ["/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro"], - "restart-on": ["grants"]} + "volumes": [ + "/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro", + "/var/lib/keycloak/admin.secret:/run/secrets/admin:ro" + ], + "restart-on": ["grants", "admin-env"]} ] } diff --git a/examples/modules/mailu.json b/examples/modules/mailu.json index 23267b7..bfc3de7 100644 --- a/examples/modules/mailu.json +++ b/examples/modules/mailu.json @@ -13,14 +13,21 @@ ], "own-secrets": { - "secret-key": "/var/lib/mailu/secret.env", - "database": "/var/lib/mailu/database.env", - "admin": "/var/lib/mailu/admin.env" + "secret-key": "/var/lib/mailu/secret-key.secret", + "database": "/var/lib/mailu/database.secret", + "admin": "/var/lib/mailu/admin.secret" }, "resources": [ {"id": "state", "type": "directory", "path": "/var/lib/mailu", "mode": "0700"}, + {"id": "secret-env", "type": "file", "path": "/var/lib/mailu/secret.env", "mode": "0600", + "content": "SECRET_KEY=${secret:secret-key}\n"}, + {"id": "database-env", "type": "file", "path": "/var/lib/mailu/database.env", "mode": "0600", + "content": "POSTGRES_USER=mailu\nPOSTGRES_DB=mailu\nPOSTGRES_PASSWORD=${secret:database}\nDB_USER=mailu\nDB_NAME=mailu\nDB_PW=${secret:database}\n"}, + {"id": "admin-env", "type": "file", "path": "/var/lib/mailu/admin.env", "mode": "0600", + "content": "INITIAL_ADMIN_PW=${secret:admin}\n"}, + {"id": "net", "type": "network", "name": "mailu"}, {"id": "resolver", "type": "container", "name": "mailu-resolver", diff --git a/examples/modules/minio.json b/examples/modules/minio.json index b4ed12a..4ac22d3 100644 --- a/examples/modules/minio.json +++ b/examples/modules/minio.json @@ -16,12 +16,15 @@ "receives": {"s3-bucket": "/var/lib/minio/grants/mesh.json"}, "grants": {"s3-bucket": "/var/lib/minio/grants"}, - "own-secrets": {"root": "/var/lib/minio/root.env"}, + "own-secrets": {"root": "/var/lib/minio/root.secret"}, "resources": [ {"id": "state", "type": "directory", "path": "/var/lib/minio", "mode": "0700"}, {"id": "grants", "type": "directory", "path": "/var/lib/minio/grants", "mode": "0700"}, + {"id": "root-env", "type": "file", "path": "/var/lib/minio/root.env", "mode": "0600", + "content": "MINIO_ROOT_USER=meshroot\nMINIO_ROOT_PASSWORD=${secret:root}\n"}, + {"id": "net", "type": "network", "name": "minio"}, {"id": "server", "type": "container", "name": "minio", @@ -30,7 +33,8 @@ "args": ["server", "/data", "--console-address", ":9001"], "env-file": ["/var/lib/minio/root.env"], "ports": ["9000:9000"], - "volumes": ["/services/minio/data/data1-1:/data"]}, + "volumes": ["/services/minio/data/data1-1:/data"], + "restart-on": ["root-env"]}, {"id": "provisioner", "type": "container", "name": "mesh-provision-objectstore", "image": "mesh-provision-objectstore@sha256:0000000000000000000000000000000000000000000000000000000000000000", @@ -43,8 +47,8 @@ }, "volumes": [ "/var/lib/minio/grants:/var/lib/minio/grants:ro", - "/var/lib/minio/root.env:/run/secrets/root:ro" + "/var/lib/minio/root.secret:/run/secrets/root:ro" ], - "restart-on": ["grants"]} + "restart-on": ["grants", "root-env"]} ] } diff --git a/examples/modules/modules_test.go b/examples/modules/modules_test.go index 9ab747e..5973cd4 100644 --- a/examples/modules/modules_test.go +++ b/examples/modules/modules_test.go @@ -2,8 +2,10 @@ package modules import ( "encoding/json" + "fmt" "os" "path/filepath" + "regexp" "strings" "testing" @@ -263,3 +265,97 @@ func TestTheObjectStoreEdgeFitsTogether(t *testing.T) { consumer.Contributes[provision]) } } + +// Every hole an example leaves for a credential can be filled from what that module declared. +// +// **A manifest that parses is not a manifest that works.** These say `${secret:x}` in a file and +// declare `x` under `own-secrets`; if the two ever disagree the mesh refuses the whole declaration +// at push time, on the machine, with the module's name and nothing else to go on. Checking it here +// costs nothing and moves the answer to whoever edited the file. +// +// This is also the shape that was missing entirely until 2026-09-01: an own secret arrives as a +// file whose whole content is the password, and every one of these programs reads `KEY=value`. The +// manifests said `own-secrets` pointed at a `.env` and it did not — it pointed at a password. +func TestEveryCredentialHoleCanBeFilledByTheModuleThatLeftIt(t *testing.T) { + found, err := filepath.Glob("*.json") + if err != nil { + t.Fatal(err) + } + var checked int + for _, name := range found { + m := read(t, name) + has := map[string]bool{} + for own := range m.OwnSecrets { + has[own] = true + } + for required := range m.Secrets { + has[required] = true + } + for _, r := range m.Resources { + content, ok := r["content"].(string) + if !ok { + continue + } + for _, wanted := range secretsUsedForTest(content) { + checked++ + if !has[wanted] { + t.Errorf( + "%s: %v says ${secret:%s}, and %s neither owns a secret by that name "+ + "nor requires anything that grants one", + name, r["id"], wanted, m.Module) + } + } + } + } + if checked == 0 { + t.Fatal("no example puts a credential into a file, so this test proves nothing") + } +} + +// A secret file is a password and nothing else, so nothing may read one as an env file. +// +// The fault this catches is the one these manifests shipped with: `own-secrets` pointing at a +// path called `.env`, mounted as `env-file`, holding a bare password. Docker reads that as a +// malformed line and the container starts with no password at all. +func TestNoContainerReadsABarePasswordAsAnEnvFile(t *testing.T) { + found, _ := filepath.Glob("*.json") + for _, name := range found { + m := read(t, name) + bare := map[string]bool{} + for _, where := range m.OwnSecrets { + bare[where] = true + } + for _, where := range m.Secrets { + bare[where] = true + } + for _, r := range m.Resources { + files, ok := r["env-file"].([]any) + if !ok { + continue + } + for _, f := range files { + if bare[fmt.Sprint(f)] { + t.Errorf( + "%s: %v reads %s as an env file, and that path holds a bare password — "+ + "declare a file whose content says ${secret:...} and read that instead", + name, r["id"], f) + } + } + } + } +} + +// The same expression the control plane and the host both match. +var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) + +func secretsUsedForTest(content string) []string { + var used []string + seen := map[string]bool{} + for _, m := range placeholder.FindAllStringSubmatch(content, -1) { + if !seen[m[1]] { + seen[m[1]] = true + used = append(used, m[1]) + } + } + return used +} diff --git a/examples/modules/postgres.json b/examples/modules/postgres.json index 6760b67..af6ac1f 100644 --- a/examples/modules/postgres.json +++ b/examples/modules/postgres.json @@ -17,12 +17,16 @@ "receives": {"postgres-database": "/var/lib/postgres/grants/mesh.json"}, "grants": {"postgres-database": "/var/lib/postgres/grants"}, - "own-secrets": {"superuser": "/var/lib/postgres/superuser.env"}, + "own-secrets": {"superuser": "/var/lib/postgres/superuser.secret"}, "resources": [ {"id": "state", "type": "directory", "path": "/var/lib/postgres", "mode": "0700"}, {"id": "grants", "type": "directory", "path": "/var/lib/postgres/grants", "mode": "0700"}, + {"id": "superuser-env", "type": "file", "path": "/var/lib/postgres/superuser.env", + "mode": "0600", + "content": "POSTGRES_PASSWORD=${secret:superuser}\n"}, + {"id": "net", "type": "network", "name": "postgres"}, {"id": "server", "type": "container", "name": "postgres", @@ -31,20 +35,21 @@ "env": {"POSTGRES_USER": "postgres", "POSTGRES_DB": "postgres"}, "env-file": ["/var/lib/postgres/superuser.env"], "ports": ["5432:5432"], - "volumes": ["/services/postgres/db-data:/var/lib/postgresql/data"]}, + "volumes": ["/services/postgres/db-data:/var/lib/postgresql/data"], + "restart-on": ["superuser-env"]}, {"id": "provisioner", "type": "container", "name": "mesh-provision-postgres", "image": "mesh-provision-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "postgres", "env": { "GRANTS": "/var/lib/postgres/grants", - "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable" + "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable", + "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser" }, - "env-file": ["/var/lib/postgres/superuser.env"], "volumes": [ "/var/lib/postgres/grants:/var/lib/postgres/grants:ro", - "/var/lib/postgres/superuser.env:/var/lib/postgres/superuser.env:ro" + "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" ], - "restart-on": ["grants"]} + "restart-on": ["grants", "superuser-env"]} ] } diff --git a/mesh-builder b/mesh-builder new file mode 100755 index 0000000..ac7062e Binary files /dev/null and b/mesh-builder differ