Merge main
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
ARG GO_BASE
|
||||||
|
ARG ALPINE_BASE
|
||||||
|
# builder's own image: the build machine itself, compiled into a container.
|
||||||
|
#
|
||||||
|
# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder,
|
||||||
|
# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one
|
||||||
|
# half of. This module ships the packaging, not a second copy of the source, so the build context
|
||||||
|
# is the mesh-controller repository root (declared under build.artifacts[].context), and this
|
||||||
|
# Dockerfile compiles ./cmd/mesh-builder from it — the same shape route-proxy already uses for the
|
||||||
|
# same reason.
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
|
||||||
|
|
||||||
|
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
|
||||||
|
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
|
||||||
|
# not a second copy of either tool vendored into this image. apk installs both from the base's own
|
||||||
|
# packages, not fetched on its own at build time.
|
||||||
|
FROM ${ALPINE_BASE}
|
||||||
|
RUN apk add --no-cache docker-cli git
|
||||||
|
COPY --from=build /mesh-builder /usr/local/bin/mesh-builder
|
||||||
|
ENTRYPOINT ["/usr/local/bin/mesh-builder"]
|
||||||
+36
-23
@@ -11,14 +11,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requires": [
|
"requires": [
|
||||||
"artifact-store"
|
"artifact-store",
|
||||||
|
"npm-package-registry"
|
||||||
],
|
],
|
||||||
|
"binds": {
|
||||||
|
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
|
||||||
|
},
|
||||||
"emits": [
|
"emits": [
|
||||||
"module.builder.built"
|
"module.builder.built"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"broker": "/var/lib/mesh/builder/broker",
|
"broker": "/var/lib/mesh/builder/broker"
|
||||||
"npm-password": "/var/lib/mesh/builder/npm-password"
|
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
@@ -38,27 +44,13 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mesh/builder/builder.env",
|
"path": "/var/lib/mesh/builder/builder.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/var/lib/builder/workspace\n"
|
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "package-binding",
|
|
||||||
"type": "file",
|
|
||||||
"path": "/var/lib/mesh/builder/package-registry.json",
|
|
||||||
"mode": "0600",
|
|
||||||
"merge": "json",
|
|
||||||
"protected": [
|
|
||||||
"provision",
|
|
||||||
"from",
|
|
||||||
"at",
|
|
||||||
"as"
|
|
||||||
],
|
|
||||||
"content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-builder",
|
"name": "mesh-builder",
|
||||||
"image": "mesh-builder@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
"artifact": "server",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mesh/builder/builder.env"
|
"/var/lib/mesh/builder/builder.env"
|
||||||
],
|
],
|
||||||
@@ -68,11 +60,32 @@
|
|||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"builder-env",
|
"builder-env"
|
||||||
"package-binding",
|
|
||||||
"needs-npm-password"
|
|
||||||
],
|
],
|
||||||
"network": "host"
|
"network": "host"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"kind": "image",
|
||||||
|
"from": "Dockerfile",
|
||||||
|
"context": {
|
||||||
|
"repository": "https://git.novox.be/novox/mesh-controller.git",
|
||||||
|
"ref": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"on": [
|
||||||
|
{
|
||||||
|
"arg": "GO_BASE",
|
||||||
|
"image": "golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "ALPINE_BASE",
|
||||||
|
"image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-23
@@ -352,24 +352,34 @@ export class GiteaAdmin {
|
|||||||
GiteaAdmin.fail("/orgs", res);
|
GiteaAdmin.fail("/orgs", res);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure the org's package team exists, granting read+write on packages, and return its id. The
|
/** Ensure the org's package team exists with exactly these units, and return its id. Found or
|
||||||
* team is found by name if it is already there, created otherwise; a lost create race is resolved
|
* created, the units are applied either way — a team is configuration the reconcile loop owns,
|
||||||
* by re-listing. */
|
* the same as a user's password, so a unit this code gains reaches a team that already exists
|
||||||
|
* rather than only the next mesh raised from scratch. A lost create race is resolved by
|
||||||
|
* re-listing. */
|
||||||
async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
|
async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
|
||||||
|
// The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a
|
||||||
|
// unit the team does not have — so code read must be said here: without it gitea answers a
|
||||||
|
// member's clone of a private repository with "not found", which is how the builder's first
|
||||||
|
// credentialed clone failed against a team that named only packages.
|
||||||
|
const units = {
|
||||||
|
permission: "read",
|
||||||
|
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
|
||||||
|
includes_all_repositories: true,
|
||||||
|
can_create_org_repo: false,
|
||||||
|
};
|
||||||
const found = await this.findTeam(org, team);
|
const found = await this.findTeam(org, team);
|
||||||
if (found !== null) return found;
|
if (found !== null) {
|
||||||
|
const patch = await this.request(`/teams/${found}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ name: team, ...units }),
|
||||||
|
});
|
||||||
|
if (patch.status === 200) return found;
|
||||||
|
GiteaAdmin.fail(`/teams/${found}`, patch);
|
||||||
|
}
|
||||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
|
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: team, ...units }),
|
||||||
name: team,
|
|
||||||
permission: "read",
|
|
||||||
// Package access is a per-unit grant; the team needs write on the packages unit and nothing
|
|
||||||
// else. includes_all_repositories keeps the team's repo view whole without widening its
|
|
||||||
// repo permission beyond read.
|
|
||||||
units_map: { "repo.packages": packageWrite ? "write" : "read" },
|
|
||||||
includes_all_repositories: true,
|
|
||||||
can_create_org_repo: false,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (res.status === 201) return Number(res.body?.id);
|
if (res.status === 201) return Number(res.body?.id);
|
||||||
if (res.status === 422 || res.status === 409) {
|
if (res.status === 422 || res.status === 409) {
|
||||||
@@ -380,14 +390,18 @@ export class GiteaAdmin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async findTeam(org: string, team: string): Promise<number | null> {
|
private async findTeam(org: string, team: string): Promise<number | null> {
|
||||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
|
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||||
if (res.status !== 200) return null;
|
if (res.status !== 200) return null;
|
||||||
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
|
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
|
||||||
return match ? Number(match.id) : null;
|
return match ? Number(match.id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure a user exists with exactly this password. Created if absent; if already there, its
|
/** Ensure a user exists with exactly this password. Created if absent; if already there, its
|
||||||
* password is patched — so the mesh minting a new secret takes on the next reconcile. */
|
* password is patched — so the mesh minting a new secret takes on the next reconcile.
|
||||||
|
*
|
||||||
|
* The edit path is taken only when the user actually exists. A 422 from the create is also what
|
||||||
|
* a plain validation failure returns, and reading it as "already there" made the follow-up edit
|
||||||
|
* 404 — burying the create's own message, which is the one that says what is actually wrong. */
|
||||||
async ensureUser(username: string, password: string, email: string): Promise<void> {
|
async ensureUser(username: string, password: string, email: string): Promise<void> {
|
||||||
const res = await this.request("/admin/users", {
|
const res = await this.request("/admin/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -395,13 +409,18 @@ export class GiteaAdmin {
|
|||||||
});
|
});
|
||||||
if (res.status === 201) return;
|
if (res.status === 201) return;
|
||||||
if (res.status === 422 || res.status === 409) {
|
if (res.status === 422 || res.status === 409) {
|
||||||
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
|
const seen = await this.request(`/users/${encodeURIComponent(username)}`);
|
||||||
method: "PATCH",
|
if (seen.status === 200) {
|
||||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
|
||||||
body: JSON.stringify({ login_name: username, password, must_change_password: false }),
|
method: "PATCH",
|
||||||
});
|
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
||||||
if (patch.status === 200) return;
|
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
|
||||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
// password, so the provisioner's check reports it lost; applying again must undo both.
|
||||||
|
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
|
||||||
|
});
|
||||||
|
if (patch.status === 200) return;
|
||||||
|
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
GiteaAdmin.fail("/admin/users", res);
|
GiteaAdmin.fail("/admin/users", res);
|
||||||
}
|
}
|
||||||
@@ -416,6 +435,29 @@ export class GiteaAdmin {
|
|||||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
||||||
|
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
||||||
|
* API, and membership through the admin API. `false` for a refused login or a missing member; any
|
||||||
|
* other answer rejects (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
|
||||||
|
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
|
||||||
|
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
|
||||||
|
});
|
||||||
|
if (me.status === 401 || me.status === 403) return false;
|
||||||
|
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
|
||||||
|
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||||
|
if (teams.status === 404) return false;
|
||||||
|
if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams);
|
||||||
|
const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team);
|
||||||
|
if (!found) return false;
|
||||||
|
const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`);
|
||||||
|
if (member.status === 200 || member.status === 204) return true;
|
||||||
|
if (member.status === 404) return false;
|
||||||
|
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
|
||||||
|
}
|
||||||
|
|
||||||
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
|
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
|
||||||
* an error, so a re-run of remove is safe. */
|
* an error, so a re-run of remove is safe. */
|
||||||
async deleteUser(username: string): Promise<void> {
|
async deleteUser(username: string): Promise<void> {
|
||||||
|
|||||||
+36
-10
@@ -11,8 +11,16 @@
|
|||||||
"name": "gitea"
|
"name": "gitea"
|
||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "git",
|
"web": {
|
||||||
"port": 3000
|
"label": "git",
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
"internal-api-refused": {
|
||||||
|
"label": "git",
|
||||||
|
"path": "/api/internal",
|
||||||
|
"deny": true,
|
||||||
|
"priority": 100000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -42,25 +50,39 @@
|
|||||||
"why": "the forge, over http"
|
"why": "the forge, over http"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"port": 2222,
|
"port": 22,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it"
|
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"serves": {
|
"serves": {
|
||||||
"package-registry": {
|
"npm-package-registry": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"npm-path": "/api/packages/novox/npm/"
|
"npm-path": "/api/packages/novox/npm/"
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"scheme": "http",
|
||||||
|
"port": 3000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"receives": {
|
"receives": {
|
||||||
"package-registry": "/var/lib/gitea/grants/mesh.json"
|
"npm-package-registry": "/var/lib/gitea/grants/npm.json"
|
||||||
},
|
},
|
||||||
"grants": {
|
"grants": {
|
||||||
"package-registry": "/var/lib/gitea/grants"
|
"npm-package-registry": "/var/lib/gitea/grants"
|
||||||
},
|
},
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "npm-package-registry",
|
||||||
|
"scope": "mesh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "git",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"broker": "/var/lib/mesh/gitea/broker"
|
"broker": "/var/lib/mesh/gitea/broker"
|
||||||
},
|
},
|
||||||
@@ -118,7 +140,7 @@
|
|||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
"3000",
|
"3000",
|
||||||
"22"
|
"222:22"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/gitea/gitea:/data"
|
"/services/gitea/gitea:/data"
|
||||||
@@ -177,7 +199,7 @@
|
|||||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
||||||
"MESH_GITEA_STATE_DIR": "/run/state",
|
"MESH_GITEA_STATE_DIR": "/run/state",
|
||||||
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
|
"MESH_RECEIVES": "/var/lib/gitea/grants/npm.json"
|
||||||
},
|
},
|
||||||
"artifact": "runtime",
|
"artifact": "runtime",
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
@@ -187,7 +209,11 @@
|
|||||||
],
|
],
|
||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
"name": "package-registry",
|
"name": "npm-package-registry",
|
||||||
|
"scope": "mesh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "git",
|
||||||
"scope": "mesh"
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry`
|
// gitea's provisioner — the adapter that makes gitea a provider of the mesh
|
||||||
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
|
// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the
|
||||||
// the sdk harness's; this writes only the per-service half: how gitea creates and removes a
|
// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea
|
||||||
// consumer's npm credential (novox/hq ADR 0048/0076).
|
// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076).
|
||||||
//
|
//
|
||||||
// The `package-registry` interface: a consumer authenticates to the npm registry at
|
// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat
|
||||||
|
// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another
|
||||||
|
// `receives` path and another registration below — not widening this one. `git`, which gitea also
|
||||||
|
// provides, mints nothing and so registers nothing here: the mesh's own repositories are public,
|
||||||
|
// and a clone credential is not yet decided (ADR 0111).
|
||||||
|
//
|
||||||
|
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
|
||||||
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
||||||
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
|
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
|
||||||
// `novox`; a consumer is a gitea *user* placed on that org's package team.
|
// `novox`; a consumer is a gitea *user* placed on that org's package team.
|
||||||
@@ -26,7 +32,11 @@ const PACKAGE_TEAM = "packages";
|
|||||||
|
|
||||||
const gitea = GiteaAdmin.fromEnv();
|
const gitea = GiteaAdmin.fromEnv();
|
||||||
|
|
||||||
runProvisioner("package-registry", {
|
// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written
|
||||||
|
// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that
|
||||||
|
// path in code would drift from it. One variable carries one path, so a second registration in this
|
||||||
|
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
||||||
|
runProvisioner("npm-package-registry", {
|
||||||
async create(p: Provision): Promise<void> {
|
async create(p: Provision): Promise<void> {
|
||||||
// The org and its package team are the same for every consumer; ensuring them per-create is
|
// The org and its package team are the same for every consumer; ensuring them per-create is
|
||||||
// idempotent and needs no separate bootstrap step.
|
// idempotent and needs no separate bootstrap step.
|
||||||
@@ -34,11 +44,21 @@ runProvisioner("package-registry", {
|
|||||||
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
|
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
|
||||||
// The user carries the consumer's login and the mesh's minted password, set every run so a
|
// The user carries the consumer's login and the mesh's minted password, set every run so a
|
||||||
// rotation takes. Membership of the package team is what grants read+write on packages.
|
// rotation takes. Membership of the package team is what grants read+write on packages.
|
||||||
await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`);
|
//
|
||||||
|
// The address is gitea's own convention for one that is not real: its email validation
|
||||||
|
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
|
||||||
|
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
|
||||||
|
// hidden addresses.
|
||||||
|
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||||
await gitea.addUserToTeam(teamId, p.as);
|
await gitea.addUserToTeam(teamId, p.as);
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string }): Promise<void> {
|
||||||
await gitea.deleteUser(p.as);
|
await gitea.deleteUser(p.as);
|
||||||
},
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,14 @@
|
|||||||
"bucket": "invoicing"
|
"bucket": "invoicing"
|
||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "invoicing",
|
"site": {
|
||||||
"port": 80
|
"label": "invoicing",
|
||||||
|
"port": 80
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"label": "invoicing-api",
|
||||||
|
"port": 9000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -63,7 +69,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/invoicing/api.env",
|
"path": "/var/lib/invoicing/api.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
|
|||||||
@@ -94,6 +94,39 @@ export class LavinmqClient {
|
|||||||
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
|
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a consumer's user exists with exactly this password and full permissions on its own
|
||||||
|
* vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the
|
||||||
|
* password is checked by hashing it with the stored salt rather than by logging in. `false` when
|
||||||
|
* the user or its permission is gone or the password differs; an unreachable API rejects
|
||||||
|
* (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async holdsConsumer(login: string, password: string): Promise<boolean> {
|
||||||
|
const v = encodeURIComponent(login);
|
||||||
|
const u = encodeURIComponent(login);
|
||||||
|
const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`);
|
||||||
|
if (!user?.password_hash) return false;
|
||||||
|
if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) {
|
||||||
|
throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`);
|
||||||
|
}
|
||||||
|
const stored = Buffer.from(user.password_hash, "base64");
|
||||||
|
if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false;
|
||||||
|
const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`);
|
||||||
|
return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */
|
||||||
|
private async getOrNull<T>(path: string): Promise<T | null> {
|
||||||
|
const resp = await fetch(`${this.conn.base}/api${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (resp.status === 404) return null;
|
||||||
|
if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`);
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
|
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
|
||||||
async removeConsumer(login: string): Promise<void> {
|
async removeConsumer(login: string): Promise<void> {
|
||||||
const v = encodeURIComponent(login);
|
const v = encodeURIComponent(login);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -48,4 +48,9 @@ runProvisioner("amqp", {
|
|||||||
await lavinmq.removeConsumer(p.as);
|
await lavinmq.removeConsumer(p.as);
|
||||||
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
|
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
|
||||||
},
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return lavinmq.holdsConsumer(p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
|
|||||||
# resolved away.
|
# resolved away.
|
||||||
WORKDIR /app/modules/mailu
|
WORKDIR /app/modules/mailu
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
FROM ${RUNTIME_BASE}
|
||||||
@@ -27,4 +27,4 @@ COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
|
|||||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||||
# ran no provisioner at all.
|
# ran no provisioner at all.
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js
|
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own
|
||||||
|
# artifact: it is a config-baked sidecar of this mail server, not a standalone application
|
||||||
|
# (novox/hq ADR 0015 draws that line at applications).
|
||||||
|
#
|
||||||
|
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's
|
||||||
|
# `build.on`. The build context is the module's own directory; every ADD says so.
|
||||||
|
ARG PYTHON_BASE
|
||||||
|
|
||||||
|
FROM ${PYTHON_BASE}
|
||||||
|
RUN apk add --no-cache bash sqlite
|
||||||
|
WORKDIR /automx2
|
||||||
|
|
||||||
|
ADD automx/files/setupvenv.sh /automx2/setupvenv.sh
|
||||||
|
ADD automx/files/start /automx2/start
|
||||||
|
ADD automx/files/setup /automx2/setup
|
||||||
|
ADD automx/files/setup-db /automx2/setup-db
|
||||||
|
ADD automx/files/add-domains /automx2/add-domains
|
||||||
|
RUN chmod u+x setupvenv.sh start add-domains setup setup-db
|
||||||
|
|
||||||
|
RUN ./setupvenv.sh \
|
||||||
|
&& . .venv/bin/activate \
|
||||||
|
&& pip install automx2==2021.6
|
||||||
|
|
||||||
|
# The launcher `start` expects. In the predecessor's image this wrapper appeared during a build
|
||||||
|
# step that never made it into the files this module carries — the image worked and the recipe
|
||||||
|
# could not reproduce it. Written here explicitly, verbatim from the proven image, so the build
|
||||||
|
# is the whole truth about the image again.
|
||||||
|
RUN mkdir -p .venv/scripts && printf '%s\n' \
|
||||||
|
'#!/usr/bin/env bash' \
|
||||||
|
'set -euo pipefail' \
|
||||||
|
'. .venv/bin/activate' \
|
||||||
|
"export FLASK_ENV='production'" \
|
||||||
|
"export FLASK_APP='automx2.server:app'" \
|
||||||
|
'flask "$@"' > .venv/scripts/flask.sh && chmod +x .venv/scripts/flask.sh
|
||||||
|
|
||||||
|
ENV AUTOMX2_CONF=/etc/automx2.conf
|
||||||
|
ADD automx/files/automx2.conf /etc/automx2.conf
|
||||||
|
|
||||||
|
# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on
|
||||||
|
# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead.
|
||||||
|
ENTRYPOINT ["/bin/sh"]
|
||||||
|
CMD ["./start"]
|
||||||
|
|
||||||
|
EXPOSE 4243
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "${MAIL_DOMAINS}"
|
||||||
|
|
||||||
|
# Split domains into array
|
||||||
|
IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}"
|
||||||
|
|
||||||
|
# User configurable section -- START
|
||||||
|
PROVIDER_ID=001
|
||||||
|
SQL_CMD="";
|
||||||
|
|
||||||
|
# Iterate domains resulting from split on second arg
|
||||||
|
for element in "${array[@]}"
|
||||||
|
do
|
||||||
|
# Set vars
|
||||||
|
DOMAIN=$element
|
||||||
|
PROVIDER_NAME=$DOMAIN
|
||||||
|
PROVIDER_SHORTNAME=$DOMAIN
|
||||||
|
|
||||||
|
# Optional LDAP server
|
||||||
|
#LDAP_SERVER="ldap.${DOMAIN}"
|
||||||
|
# User configurable section -- END
|
||||||
|
s1_id=$((PROVIDER_ID + 1))
|
||||||
|
s2_id=$((PROVIDER_ID + 2))
|
||||||
|
s3_id=$((PROVIDER_ID + 3))
|
||||||
|
dom_id=$((PROVIDER_ID + 4))
|
||||||
|
|
||||||
|
s3_id='NULL'
|
||||||
|
|
||||||
|
SQL_CMD=$(cat <<EOT
|
||||||
|
$SQL_CMD
|
||||||
|
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
|
||||||
|
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
|
||||||
|
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
|
||||||
|
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
|
||||||
|
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
|
||||||
|
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
|
||||||
|
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
|
||||||
|
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
PROVIDER_ID=$((PROVIDER_ID+10))
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e ${SQL_CMD}
|
||||||
|
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[automx2]
|
||||||
|
# A typical production setup would use loglevel = WARNING
|
||||||
|
loglevel = WARNING
|
||||||
|
# Echo SQL commands into log? Used for debugging.
|
||||||
|
db_echo = false
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory SQLite database
|
||||||
|
# db_uri = sqlite:///:memory:
|
||||||
|
|
||||||
|
# SQLite database in a UNIX-like file system
|
||||||
|
db_uri = sqlite:////data/db.sqlite
|
||||||
|
|
||||||
|
# MySQL database on a remote server. This example does not use an encrypted
|
||||||
|
# connection and is therefore *not* recommended for production use.
|
||||||
|
#db_uri = mysql://username:password@server.example.com/db
|
||||||
|
|
||||||
|
# Number of proxy servers between automx2 and the client (default: 0).
|
||||||
|
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
|
||||||
|
# connections, proxy_count probably needs to be changed.
|
||||||
|
proxy_count = 1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -e /data/db.sqlite ]; then
|
||||||
|
# DB SETUP
|
||||||
|
echo "SETTING UP DB"
|
||||||
|
./setup-db
|
||||||
|
|
||||||
|
echo "ADDING DOMAINS"
|
||||||
|
# Add the domains
|
||||||
|
./add-domains
|
||||||
|
fi
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# LDAP-Server
|
||||||
|
LDAP=$(cat <<EOT
|
||||||
|
CREATE TABLE ldapserver(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
port INT NOT NULL,
|
||||||
|
use_ssl INT NOT NULL,
|
||||||
|
search_base TEXT NOT NULL,
|
||||||
|
search_filter TEXT NOT NULL,
|
||||||
|
attr_uid TEXT NOT NULL,
|
||||||
|
attr_cn TEXT NOT NULL,
|
||||||
|
bind_password TEXT NOT NULL,
|
||||||
|
bind_user TEXT NOT NULL
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider
|
||||||
|
PROVIDER=$(cat <<EOT
|
||||||
|
CREATE TABLE provider(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
short_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server
|
||||||
|
SERVER=$(cat <<EOT
|
||||||
|
CREATE TABLE server(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
prio INT NOT NULL DEFAULT 10,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
port INT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
socket_type TEXT NOT NULL,
|
||||||
|
user_name TEXT NOT NULL,
|
||||||
|
authentication TEXT NOT NULL
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Domain
|
||||||
|
DOMAIN=$(cat <<EOT
|
||||||
|
CREATE TABLE domain(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
provider_id INT NOT NULL,
|
||||||
|
ldapserver_id INT NULL,
|
||||||
|
FOREIGN KEY(ldapserver_id) REFERENCES ldapserver(id),
|
||||||
|
FOREIGN KEY(provider_id) REFERENCES provider(id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX domain_name ON domain(name);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server-Domain
|
||||||
|
SERVER_DOMAIN=$(cat <<EOT
|
||||||
|
CREATE TABLE server_domain(
|
||||||
|
server_id INT NOT NULL,
|
||||||
|
domain_id INT NOT NULL,
|
||||||
|
FOREIGN KEY(server_id) REFERENCES server(id),
|
||||||
|
FOREIGN KEY(domain_id) REFERENCES domain(id)
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
## TODO Foreign keys
|
||||||
|
|
||||||
|
SQL_CMD=$(cat <<EOT
|
||||||
|
$LDAP
|
||||||
|
$PROVIDER
|
||||||
|
$SERVER
|
||||||
|
$DOMAIN
|
||||||
|
$SERVER_DOMAIN
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
echo -e ${SQL_CMD}
|
||||||
|
|
||||||
|
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# vim:ts=4:sw=4:noet
|
||||||
|
#
|
||||||
|
# Creates a Python 3 virtual environment. The target directory can be passed
|
||||||
|
# as a parameter. The default path is '.venv' in the current directory.
|
||||||
|
|
||||||
|
dir="${1:-.venv}"
|
||||||
|
echo "Setup dir $dir"
|
||||||
|
|
||||||
|
set -e
|
||||||
|
if [ -d "${dir}" ]; then
|
||||||
|
echo >&2 "Directory '${dir}' already exists, exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 -m venv "${dir}"
|
||||||
|
source "${dir}/bin/activate"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
pip install -U pip setuptools wheel || true
|
||||||
|
|
||||||
|
#set -e
|
||||||
|
## vim:tabstop=4:noexpandtab
|
||||||
|
##
|
||||||
|
## Creates a Python 3 virtual environment. The target directory can be passed
|
||||||
|
## as a parameter. The default path is 'venv' in the current directory.
|
||||||
|
#
|
||||||
|
#dir="${1:-venv}"
|
||||||
|
#
|
||||||
|
#set -e
|
||||||
|
#if [ -d "${dir}" ]; then
|
||||||
|
# echo "Directory '${dir}' already exists, exiting." >&2
|
||||||
|
# exit 1
|
||||||
|
#fi
|
||||||
|
#python3 -m venv "${dir}"
|
||||||
|
#. "${dir}/bin/activate"
|
||||||
|
#
|
||||||
|
#set +e
|
||||||
|
#pip install -U pip setuptools || true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
./setup
|
||||||
|
|
||||||
|
# Start
|
||||||
|
./.venv/scripts/flask.sh run --host=0.0.0.0 --port=4243
|
||||||
@@ -130,10 +130,39 @@ export class MailuClient {
|
|||||||
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
|
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the mesh's password on a mailbox the mesh provisions, and enable it. A disabled mailbox is
|
||||||
|
* what the provisioner's check reports as lost, so applying again must enable it, or the two would
|
||||||
|
* disagree for ever. Separate from changePassword, which an operator's tool uses and which must
|
||||||
|
* not re-enable a mailbox someone disabled.
|
||||||
|
*/
|
||||||
|
async applyProvisioned(email: string, password: string): Promise<void> {
|
||||||
|
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
async deleteUser(email: string): Promise<void> {
|
async deleteUser(email: string): Promise<void> {
|
||||||
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
|
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a mailbox exists and is enabled. Read-only, through the admin API.
|
||||||
|
*
|
||||||
|
* **The password is not checked.** Mailu authenticates in its admin service, behind the front;
|
||||||
|
* the imap server's own password database accepts any password from Mailu's subnet, so asking it
|
||||||
|
* (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught;
|
||||||
|
* a password changed by hand is not (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async holdsUser(email: string): Promise<boolean> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, {
|
||||||
|
headers: { Authorization: this.apiKey, Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (res.status === 404) return false;
|
||||||
|
if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`);
|
||||||
|
const user = (await res.json()) as { enabled?: boolean };
|
||||||
|
return user.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async listAliases(): Promise<MailuAlias[]> {
|
async listAliases(): Promise<MailuAlias[]> {
|
||||||
const aliases = await this.api<any[]>("GET", "/alias");
|
const aliases = await this.api<any[]>("GET", "/alias");
|
||||||
return (aliases ?? []).map((a) => ({
|
return (aliases ?? []).map((a) => ({
|
||||||
|
|||||||
+177
-31
@@ -14,8 +14,30 @@
|
|||||||
"name": "mailu"
|
"name": "mailu"
|
||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "mail",
|
"web": {
|
||||||
"port": 7080
|
"label": "mail",
|
||||||
|
"port": 7443,
|
||||||
|
"scheme": "https",
|
||||||
|
"insecure": true
|
||||||
|
},
|
||||||
|
"acme": {
|
||||||
|
"label": "mail",
|
||||||
|
"path": "/.well-known/acme-challenge",
|
||||||
|
"port": 7080,
|
||||||
|
"priority": 100
|
||||||
|
},
|
||||||
|
"autoconfig": {
|
||||||
|
"label": "autoconfig",
|
||||||
|
"port": 4243
|
||||||
|
},
|
||||||
|
"autodiscover": {
|
||||||
|
"label": "autodiscover",
|
||||||
|
"port": 4243
|
||||||
|
},
|
||||||
|
"automx": {
|
||||||
|
"label": "automx",
|
||||||
|
"port": 4243
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -44,6 +66,20 @@
|
|||||||
"why": "mail from other mail servers",
|
"why": "mail from other mail servers",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"port": 110,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "anywhere",
|
||||||
|
"why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change",
|
||||||
|
"fixed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 143,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "anywhere",
|
||||||
|
"why": "IMAP with STARTTLS, kept at parity",
|
||||||
|
"fixed": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"port": 465,
|
"port": 465,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
@@ -55,7 +91,7 @@
|
|||||||
"port": 587,
|
"port": 587,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
"why": "submission",
|
"why": "submission; also what the smtp provision serves consumers",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,11 +101,30 @@
|
|||||||
"why": "IMAP over TLS",
|
"why": "IMAP over TLS",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"port": 995,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "anywhere",
|
||||||
|
"why": "POP3 over TLS, kept at parity",
|
||||||
|
"fixed": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"port": 7080,
|
"port": 7080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the web interface (admin, webmail, admin API), behind the route proxy"
|
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 7443,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 4243,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
@@ -88,12 +143,24 @@
|
|||||||
"path": "/var/lib/mailu",
|
"path": "/var/lib/mailu",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "grants",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/var/lib/mailu/grants",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data-automx",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/services/mailu/data/automx",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "config-env",
|
"id": "config-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mailu/mailu.env",
|
"path": "/var/lib/mailu/mailu.env",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n"
|
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "secret-env",
|
"id": "secret-env",
|
||||||
@@ -144,7 +211,7 @@
|
|||||||
"id": "data-mailqueue",
|
"id": "data-mailqueue",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/services/mailu/data/mailqueue",
|
"path": "/services/mailu/data/mailqueue",
|
||||||
"mode": "0700"
|
"mode": "0755"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "data-filter",
|
"id": "data-filter",
|
||||||
@@ -152,6 +219,12 @@
|
|||||||
"path": "/services/mailu/data/filter",
|
"path": "/services/mailu/data/filter",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "data-clamav",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/services/mailu/data/clamav",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "data-redis",
|
"id": "data-redis",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -215,19 +288,20 @@
|
|||||||
"id": "resolver",
|
"id": "resolver",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-resolver",
|
"name": "mailu-resolver",
|
||||||
"image": "ghcr.io/mailu/unbound@sha256:142aaad82ad1b0d5b59a5f1303778dba61a3e0a540f5d969c48862bcc99f6f5d",
|
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env",
|
"/var/lib/mailu/mailu.env",
|
||||||
"/var/lib/mailu/secret.env"
|
"/var/lib/mailu/secret.env"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||||
|
"ip": "192.168.203.254"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "redis",
|
"id": "redis",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-redis",
|
"name": "mailu-redis",
|
||||||
"image": "redis@sha256:1db42ccef14898aa29bae778452d567534b59c107129cbc1163fb552de184d3c",
|
"image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/redis:/data"
|
"/services/mailu/data/redis:/data"
|
||||||
@@ -237,7 +311,7 @@
|
|||||||
"id": "admin",
|
"id": "admin",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-admin",
|
"name": "mailu-admin",
|
||||||
"image": "ghcr.io/mailu/admin@sha256:dcac20e9cbdad560faef9653b1b5ac0d9266f4098dc00f0e7f0d35f4e70ed8f1",
|
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env",
|
"/var/lib/mailu/mailu.env",
|
||||||
@@ -249,13 +323,16 @@
|
|||||||
"/services/mailu/data/data:/data",
|
"/services/mailu/data/data:/data",
|
||||||
"/services/mailu/data/dkim:/dkim"
|
"/services/mailu/data/dkim:/dkim"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "imap",
|
"id": "imap",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-imap",
|
"name": "mailu-imap",
|
||||||
"image": "ghcr.io/mailu/dovecot@sha256:46d18ba51032be8ebd6841aa49c1ef8762c729038c5fd86a081b5b884d478af9",
|
"image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env"
|
"/var/lib/mailu/mailu.env"
|
||||||
@@ -263,13 +340,16 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/mail:/mail",
|
"/services/mailu/data/mail:/mail",
|
||||||
"/services/mailu/data/overrides/dovecot:/overrides:ro"
|
"/services/mailu/data/overrides/dovecot:/overrides:ro"
|
||||||
|
],
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "smtp",
|
"id": "smtp",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-smtp",
|
"name": "mailu-smtp",
|
||||||
"image": "ghcr.io/mailu/postfix@sha256:bbf882880f68849511710b35237a933f3fe80c4b28bf48ff20205dbd1f1433d7",
|
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env"
|
"/var/lib/mailu/mailu.env"
|
||||||
@@ -277,13 +357,16 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/mailqueue:/queue",
|
"/services/mailu/data/mailqueue:/queue",
|
||||||
"/services/mailu/data/overrides/postfix:/overrides:ro"
|
"/services/mailu/data/overrides/postfix:/overrides:ro"
|
||||||
|
],
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "antispam",
|
"id": "antispam",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-antispam",
|
"name": "mailu-antispam",
|
||||||
"image": "ghcr.io/mailu/rspamd@sha256:e87ab93dd252cc69499caa5317dd10d445fd4291a7ecf6bca09793c7d475a0c8",
|
"image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env"
|
"/var/lib/mailu/mailu.env"
|
||||||
@@ -291,28 +374,29 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/filter:/var/lib/rspamd",
|
"/services/mailu/data/filter:/var/lib/rspamd",
|
||||||
"/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro"
|
"/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro"
|
||||||
|
],
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "antivirus",
|
"id": "antivirus",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-antivirus",
|
"name": "mailu-antivirus",
|
||||||
"image": "ghcr.io/mailu/clamav@sha256:01d30483e4a8a20a54566addb1f9b00ebb51e8a103f9226602379c412cf5fb62",
|
"image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
|
||||||
"/var/lib/mailu/mailu.env",
|
|
||||||
"/var/lib/mailu/secret.env"
|
|
||||||
],
|
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/filter:/data"
|
"/services/mailu/data/clamav:/var/lib/clamav"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "webmail",
|
"id": "webmail",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-webmail",
|
"name": "mailu-webmail",
|
||||||
"image": "ghcr.io/mailu/roundcube@sha256:19ccc9c21b2420dabb893ffa707ef90785c785e53dcb6bb9f98da01598412c43",
|
"image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env",
|
"/var/lib/mailu/mailu.env",
|
||||||
@@ -322,13 +406,16 @@
|
|||||||
"/services/mailu/data/webmail:/data",
|
"/services/mailu/data/webmail:/data",
|
||||||
"/services/mailu/data/overrides/roundcube:/overrides:ro"
|
"/services/mailu/data/overrides/roundcube:/overrides:ro"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "webdav",
|
"id": "webdav",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-webdav",
|
"name": "mailu-webdav",
|
||||||
"image": "ghcr.io/mailu/radicale@sha256:e13cbad3791c0a6841b5d387e57e49a117808dcef87b8c9969f671ae9c3b67c0",
|
"image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env",
|
"/var/lib/mailu/mailu.env",
|
||||||
@@ -337,13 +424,16 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/dav:/data"
|
"/services/mailu/data/dav:/data"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "fetchmail",
|
"id": "fetchmail",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-fetchmail",
|
"name": "mailu-fetchmail",
|
||||||
"image": "ghcr.io/mailu/fetchmail@sha256:7dcd1392882925d612ab2d0230d437f0c660989d572283c48b0d0f2d491adce7",
|
"image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env",
|
"/var/lib/mailu/mailu.env",
|
||||||
@@ -352,27 +442,37 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/data/fetchmail:/data"
|
"/services/mailu/data/data/fetchmail:/data"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "front",
|
"id": "front",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-front",
|
"name": "mailu-front",
|
||||||
"image": "ghcr.io/mailu/nginx@sha256:09f28ab6d36367fcacc7994f7021f132ac845bdc05f04bf80906102d11aaa057",
|
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mailu/mailu.env"
|
"/var/lib/mailu/mailu.env"
|
||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
"25",
|
"25",
|
||||||
|
"110",
|
||||||
|
"143",
|
||||||
"465",
|
"465",
|
||||||
"587",
|
"587",
|
||||||
"993",
|
"993",
|
||||||
"80"
|
"995",
|
||||||
|
"7080:80",
|
||||||
|
"7443:443"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/certs:/certs",
|
"/services/mailu/data/certs:/certs",
|
||||||
"/services/mailu/data/overrides/nginx:/overrides:ro"
|
"/services/mailu/data/overrides/nginx:/overrides:ro"
|
||||||
|
],
|
||||||
|
"dns": [
|
||||||
|
"192.168.203.254"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -391,20 +491,39 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
|
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
|
||||||
|
"/var/lib/mailu/grants:/var/lib/mailu/grants:ro",
|
||||||
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_MAILU_URL": "http://mailu-admin/api/v1",
|
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
|
||||||
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
||||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json"
|
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
||||||
|
"MESH_MAILU_DOMAIN": "novox.be",
|
||||||
|
"MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json"
|
||||||
},
|
},
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"runtime-config"
|
"runtime-config"
|
||||||
],
|
],
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "automx",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mailu-automx",
|
||||||
|
"artifact": "automx",
|
||||||
|
"network": "mailu",
|
||||||
|
"env-file": [
|
||||||
|
"/var/lib/mailu/mailu.env"
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"4243"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/services/mailu/data/automx:/data"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -418,6 +537,10 @@
|
|||||||
"arg": "RUNTIME_BASE",
|
"arg": "RUNTIME_BASE",
|
||||||
"module": "mesh-tools",
|
"module": "mesh-tools",
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "PYTHON_BASE",
|
||||||
|
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
@@ -425,7 +548,30 @@
|
|||||||
"name": "runtime",
|
"name": "runtime",
|
||||||
"kind": "image",
|
"kind": "image",
|
||||||
"from": "Dockerfile"
|
"from": "Dockerfile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "automx",
|
||||||
|
"kind": "image",
|
||||||
|
"from": "automx/Dockerfile"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "smtp",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serves": {
|
||||||
|
"smtp": {
|
||||||
|
"port": 587,
|
||||||
|
"domain": "novox.be"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receives": {
|
||||||
|
"smtp": "/var/lib/mailu/grants/mesh.json"
|
||||||
|
},
|
||||||
|
"grants": {
|
||||||
|
"smtp": "/var/lib/mailu/grants"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// mailu's provisioner — the adapter that makes mailu a provider of the mesh `smtp` interface.
|
||||||
|
// The reconcile loop, the contributions file, and reading the mesh's minted password are the sdk
|
||||||
|
// harness's; this writes only the per-service half: how mailu creates and removes a consumer's
|
||||||
|
// sending account (novox/hq ADR 0048/0076, gitea's package-registry provisioner is the sibling).
|
||||||
|
//
|
||||||
|
// The `smtp` interface: a consumer authenticates to submission (port 587, STARTTLS) as a real
|
||||||
|
// mailbox this provisioner creates. The address is `<account>@<domain>`: the local part is the
|
||||||
|
// consumer's `account` contribution — the name it wants to send as — falling back to the mesh's
|
||||||
|
// own login for a consumer that named none; the domain is the mail server's, which is this
|
||||||
|
// module's fact, not the consumer's.
|
||||||
|
//
|
||||||
|
// **The password is the mesh's, not the provisioner's (ADR 0048).** The mesh mints it and hands
|
||||||
|
// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals
|
||||||
|
// nothing: the consumer already has its copy through the mesh's own channel.
|
||||||
|
|
||||||
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||||
|
import { MailuClient } from "../client.js";
|
||||||
|
|
||||||
|
const mailu = MailuClient.fromEnv();
|
||||||
|
|
||||||
|
// The mail server's own domain. From the environment the manifest composes, because the client's
|
||||||
|
// config file carries the admin API's coordinates, not the mail domain.
|
||||||
|
function domain(): string {
|
||||||
|
const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim();
|
||||||
|
if (named === "") {
|
||||||
|
throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed");
|
||||||
|
}
|
||||||
|
return named;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address one consumer sends as. The local part is refused rather than sanitised when it is
|
||||||
|
// not a plain mailbox name — a rewritten name is an address nobody asked for.
|
||||||
|
function addressOf(p: { as: string; values?: Readonly<Record<string, unknown>> }): string {
|
||||||
|
const contributed = typeof p.values?.["account"] === "string" ? (p.values["account"] as string).trim() : "";
|
||||||
|
const local = contributed !== "" ? contributed : p.as;
|
||||||
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(local)) {
|
||||||
|
throw new Error(`${JSON.stringify(local)} is not a usable mailbox name`);
|
||||||
|
}
|
||||||
|
return `${local}@${domain()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
runProvisioner("smtp", {
|
||||||
|
async create(p: Provision): Promise<void> {
|
||||||
|
const email = addressOf(p);
|
||||||
|
// Create if absent, and set exactly the minted password either way so a rotation takes.
|
||||||
|
// Mailu's create refuses a duplicate address, which is the signal to fall through to the
|
||||||
|
// password set — the same found-then-apply shape gitea's ensureUser settled on.
|
||||||
|
try {
|
||||||
|
await mailu.createUser(email, p.password);
|
||||||
|
} catch {
|
||||||
|
await mailu.applyProvisioned(email, p.password);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(p: { as: string }): Promise<void> {
|
||||||
|
// The withdrawal only knows the mesh login, never the contributed local part — so accounts
|
||||||
|
// that contributed one are removed when the address matching the login is absent? No: the
|
||||||
|
// harness hands remove only `as`, and an address composed from a contribution cannot be
|
||||||
|
// recomputed from it. The account is therefore removed by its login-shaped address when one
|
||||||
|
// exists, and left otherwise — a mailbox holding mail is the one thing a background loop
|
||||||
|
// must not guess about (this module's own events file says the same). Withdrawal of a
|
||||||
|
// named-account consumer is an operator action until the harness carries values here.
|
||||||
|
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
|
||||||
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return mailu.holdsUser(addressOf(p));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# minio's runtime: the tool runtime, carrying this module's compiled code.
|
||||||
|
#
|
||||||
|
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||||
|
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||||
|
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||||
|
# happens to have the siblings.
|
||||||
|
#
|
||||||
|
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||||
|
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||||
|
ARG BUILD_BASE
|
||||||
|
ARG RUNTIME_BASE
|
||||||
|
ARG MC_CLI
|
||||||
|
|
||||||
|
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
|
||||||
|
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
|
||||||
|
FROM ${MC_CLI} AS mccli
|
||||||
|
|
||||||
|
FROM ${BUILD_BASE} AS build
|
||||||
|
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||||
|
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
||||||
|
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
||||||
|
# resolved away.
|
||||||
|
WORKDIR /app/modules/minio
|
||||||
|
COPY . .
|
||||||
|
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
|
||||||
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
|
FROM ${RUNTIME_BASE}
|
||||||
|
COPY --from=build /app/modules/minio/dist /app/modules/minio/dist
|
||||||
|
# The provisioner shells out to mc to actually create buckets and service accounts on the running
|
||||||
|
# minio server — mc itself was never in this runtime image, only in minio's own. Silently retried
|
||||||
|
# "spawn mc ENOENT" forever: a requirement was granted at the control-plane level without ever
|
||||||
|
# materializing the credential on minio. /usr/bin/mc there is a symlink to the real binary, mcli —
|
||||||
|
# both copied so the symlink resolves.
|
||||||
|
COPY --from=mccli /usr/bin/mcli /usr/bin/mcli
|
||||||
|
COPY --from=mccli /usr/bin/mc /usr/bin/mc
|
||||||
|
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||||
|
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||||
|
# the convention novox/hq issues 060/061 settled.
|
||||||
|
ENV MESH_TOOL_MODULES=/app/modules/minio/dist/tools/index.js,/app/modules/minio/dist/provisioner/index.js
|
||||||
+17
-4
@@ -125,6 +125,18 @@ export class MinioClient {
|
|||||||
throw new Error(`minio bucketExists ${bucket}: ${status}`);
|
throw new Error(`minio bucketExists ${bucket}: ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the
|
||||||
|
* bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the
|
||||||
|
* secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise<boolean> {
|
||||||
|
const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey });
|
||||||
|
if (status === 200) return true;
|
||||||
|
if (status === 403 || status === 404) return false;
|
||||||
|
throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
async createBucket(bucket: string): Promise<void> {
|
async createBucket(bucket: string): Promise<void> {
|
||||||
const { status, text } = await this.request("PUT", `/${bucket}`);
|
const { status, text } = await this.request("PUT", `/${bucket}`);
|
||||||
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
|
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
|
||||||
@@ -251,6 +263,7 @@ export class MinioClient {
|
|||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
query: Record<string, string> = {},
|
query: Record<string, string> = {},
|
||||||
|
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
|
||||||
): Promise<{ status: number; headers: Headers; text: string }> {
|
): Promise<{ status: number; headers: Headers; text: string }> {
|
||||||
const { amzDate, dateStamp } = this.stamp();
|
const { amzDate, dateStamp } = this.stamp();
|
||||||
const host = new URL(this.baseUrl).host;
|
const host = new URL(this.baseUrl).host;
|
||||||
@@ -262,8 +275,8 @@ export class MinioClient {
|
|||||||
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
||||||
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
|
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
|
||||||
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
|
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
|
||||||
const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex");
|
const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex");
|
||||||
const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||||
|
|
||||||
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
|
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -275,8 +288,8 @@ export class MinioClient {
|
|||||||
return { status: res.status, headers: res.headers, text };
|
return { status: res.status, headers: res.headers, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
private signingKey(dateStamp: string): Buffer {
|
private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer {
|
||||||
const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp);
|
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
|
||||||
const kRegion = hmac(kDate, this.region);
|
const kRegion = hmac(kDate, this.region);
|
||||||
const kService = hmac(kRegion, "s3");
|
const kService = hmac(kRegion, "s3");
|
||||||
return hmac(kService, "aws4_request");
|
return hmac(kService, "aws4_request");
|
||||||
|
|||||||
@@ -7,6 +7,21 @@
|
|||||||
"scope": "mesh"
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requires": [
|
||||||
|
"route"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"route": {
|
||||||
|
"api": {
|
||||||
|
"label": "files-api",
|
||||||
|
"port": 9000
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"label": "files",
|
||||||
|
"port": 9001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
@@ -20,12 +35,18 @@
|
|||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the S3 endpoint"
|
"why": "the S3 endpoint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 9001,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "the admin console"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"serves": {
|
"serves": {
|
||||||
"s3-bucket": {
|
"s3-bucket": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"region": "us-east-1",
|
"region": "eu-west",
|
||||||
"port": 9000
|
"port": 9000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -68,7 +89,7 @@
|
|||||||
{
|
{
|
||||||
"id": "data",
|
"id": "data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/services/minio/data/data1-1",
|
"path": "/var/lib/minio-store",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,7 +101,7 @@
|
|||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "minio",
|
"name": "minio",
|
||||||
"image": "quay.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e",
|
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372",
|
||||||
"network": "minio",
|
"network": "minio",
|
||||||
"args": [
|
"args": [
|
||||||
"server",
|
"server",
|
||||||
@@ -92,21 +113,22 @@
|
|||||||
"/var/lib/minio/root.env"
|
"/var/lib/minio/root.env"
|
||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
"9000"
|
"9000",
|
||||||
|
"9001"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/minio/data/data1-1:/data",
|
"/var/lib/minio-store:/data",
|
||||||
"/var/lib/minio/root.secret:/run/secrets/root:ro"
|
"/var/lib/minio/root.secret:/run/secrets/root:ro"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root"
|
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
|
||||||
|
"MINIO_REGION": "eu-west"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "runtime",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-minio",
|
"name": "mesh-minio",
|
||||||
"image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
"network": "minio",
|
"network": "minio",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
|
||||||
@@ -117,9 +139,36 @@
|
|||||||
"MESH_MINIO_ENDPOINT": "http://minio:9000",
|
"MESH_MINIO_ENDPOINT": "http://minio:9000",
|
||||||
"MESH_MINIO_ROOT_USER": "meshroot",
|
"MESH_MINIO_ROOT_USER": "meshroot",
|
||||||
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
|
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
|
||||||
|
"MESH_MINIO_REGION": "eu-west",
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_RECEIVES": "/var/lib/minio/grants/mesh.json"
|
"MESH_RECEIVES": "/var/lib/minio/grants/mesh.json"
|
||||||
}
|
},
|
||||||
|
"artifact": "runtime"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"build": {
|
||||||
|
"on": [
|
||||||
|
{
|
||||||
|
"arg": "BUILD_BASE",
|
||||||
|
"module": "mesh-tools",
|
||||||
|
"artifact": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "RUNTIME_BASE",
|
||||||
|
"module": "mesh-tools",
|
||||||
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "MC_CLI",
|
||||||
|
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "runtime",
|
||||||
|
"kind": "image",
|
||||||
|
"from": "Dockerfile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ runProvisioner("s3-bucket", {
|
|||||||
|
|
||||||
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
|
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
||||||
|
|||||||
@@ -109,6 +109,34 @@ print(EJSON.stringify({ ok: 1 }));
|
|||||||
await this.evalJs<{ ok: number }>(js);
|
await this.evalJs<{ ok: number }>(js);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
|
||||||
|
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
|
||||||
|
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
|
||||||
|
// Connected without credentials, then authenticated inside the eval from the environment, so
|
||||||
|
// the consumer's password is neither on argv nor in the message of a failed command.
|
||||||
|
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
|
||||||
|
const js =
|
||||||
|
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
|
||||||
|
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
|
||||||
|
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
|
||||||
|
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
|
||||||
|
timeout: 30_000,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
|
||||||
|
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
|
||||||
|
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
|
||||||
|
}
|
||||||
|
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
|
||||||
|
return roles.some((r) => r.role === "dbOwner" && r.db === database);
|
||||||
|
}
|
||||||
|
|
||||||
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
|
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
|
||||||
* user is removed first so a re-grant of the same login starts clean. */
|
* user is removed first so a re-grant of the same login starts clean. */
|
||||||
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
|
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mongo",
|
"name": "mongodb-server",
|
||||||
"image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3",
|
"image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3",
|
||||||
"network": "mongodb",
|
"network": "mongodb",
|
||||||
"env": {
|
"env": {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
|
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_PROVISION_MONGODB": "mongodb://root@mongo:27017/admin?authSource=admin",
|
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
|
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json"
|
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -45,4 +45,9 @@ runProvisioner("mongodb-database", {
|
|||||||
await mongo.dropDatabaseAndUser(p.as, p.as);
|
await mongo.dropDatabaseAndUser(p.as, p.as);
|
||||||
await announce("module.mongodb.database.deprovisioned", { database: p.as });
|
await announce("module.mongodb.database.deprovisioned", { database: p.as });
|
||||||
},
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return mongo.canAuthenticateAs(p.as, p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
// The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README.
|
// The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README.
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { connect as tcpConnect } from "node:net";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -87,9 +88,20 @@ export class MosquittoClient {
|
|||||||
"-u", this.conn.adminUser,
|
"-u", this.conn.adminUser,
|
||||||
"-P", this.conn.adminPassword,
|
"-P", this.conn.adminPassword,
|
||||||
];
|
];
|
||||||
const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
|
let stdout: string;
|
||||||
maxBuffer: 16 << 20,
|
let stderr: string;
|
||||||
});
|
try {
|
||||||
|
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
|
||||||
|
maxBuffer: 16 << 20,
|
||||||
|
timeout: 30_000,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
// A failed run's message repeats its argv, the admin password (-P) included; say what failed
|
||||||
|
// without it.
|
||||||
|
const e = err as { code?: unknown; signal?: unknown; stderr?: string; stdout?: string };
|
||||||
|
const detail = `${e.stderr ?? ""}${e.stdout ?? ""}`.trim().slice(0, 500);
|
||||||
|
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} could not run (${e.code ?? e.signal ?? "error"}): ${detail}`);
|
||||||
|
}
|
||||||
const failure = ctlError(`${stdout}\n${stderr}`);
|
const failure = ctlError(`${stdout}\n${stderr}`);
|
||||||
if (failure) {
|
if (failure) {
|
||||||
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`);
|
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`);
|
||||||
@@ -140,6 +152,11 @@ export class MosquittoClient {
|
|||||||
|
|
||||||
if (await this.clientExists(username)) {
|
if (await this.clientExists(username)) {
|
||||||
await this.ctl("setClientPassword", username, password);
|
await this.ctl("setClientPassword", username, password);
|
||||||
|
// A disabled client is refused like a wrong password, so the check the provisioner runs
|
||||||
|
// reports it lost; applying again must enable it, or the two would disagree for ever.
|
||||||
|
if (/Disabled:\s*true/i.test(await this.ctl("getClient", username))) {
|
||||||
|
await this.ctl("enableClient", username);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.ctl("createClient", username, "-p", password);
|
await this.ctl("createClient", username, "-p", password);
|
||||||
}
|
}
|
||||||
@@ -163,6 +180,28 @@ export class MosquittoClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a consumer's client accepts exactly this password and still carries its own role.
|
||||||
|
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
|
||||||
|
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
|
||||||
|
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async holdsClient(username: string, password: string): Promise<boolean> {
|
||||||
|
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
|
||||||
|
if (code === 4 || code === 5) return false;
|
||||||
|
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
|
||||||
|
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
|
||||||
|
// unlike clientHasRole, which reads every failure as "no role".
|
||||||
|
let out: string;
|
||||||
|
try {
|
||||||
|
out = await this.ctl("getClient", username);
|
||||||
|
} catch (err) {
|
||||||
|
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a client and the per-client role created for it, idempotently. */
|
/** Remove a client and the per-client role created for it, idempotently. */
|
||||||
async deleteScopedClient(username: string): Promise<void> {
|
async deleteScopedClient(username: string): Promise<void> {
|
||||||
await ignoreMissing(this.ctl("deleteClient", username));
|
await ignoreMissing(this.ctl("deleteClient", username));
|
||||||
@@ -249,3 +288,55 @@ function readSecretFile(path: string | undefined): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect once over MQTT 3.1.1 with a username and password, return the broker's CONNACK return code,
|
||||||
|
* and disconnect. A clean session under a throwaway client id, so no consumer session is taken over.
|
||||||
|
*/
|
||||||
|
function mqttConnack(host: string, port: number, username: string, password: string): Promise<number> {
|
||||||
|
const str = (v: string): Buffer => {
|
||||||
|
const b = Buffer.from(v, "utf8");
|
||||||
|
const len = Buffer.alloc(2);
|
||||||
|
len.writeUInt16BE(b.length);
|
||||||
|
return Buffer.concat([len, b]);
|
||||||
|
};
|
||||||
|
const variable = Buffer.concat([str("MQTT"), Buffer.from([4, 0xc2, 0, 10])]); // level 4; user+pass+clean; keepalive 10s
|
||||||
|
const payload = Buffer.concat([str(`mesh-holds-${randomBytes(6).toString("hex")}`), str(username), str(password)]);
|
||||||
|
let remaining = variable.length + payload.length;
|
||||||
|
const lenBytes: number[] = [];
|
||||||
|
do {
|
||||||
|
let byte = remaining % 128;
|
||||||
|
remaining = Math.floor(remaining / 128);
|
||||||
|
if (remaining > 0) byte |= 0x80;
|
||||||
|
lenBytes.push(byte);
|
||||||
|
} while (remaining > 0);
|
||||||
|
const packet = Buffer.concat([Buffer.from([0x10, ...lenBytes]), variable, payload]);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = tcpConnect({ host, port });
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error(`no CONNACK from ${host}:${port} within 10s`));
|
||||||
|
}, 10_000);
|
||||||
|
socket.on("connect", () => socket.write(packet));
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
if (buf.length < 4) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (buf[0] !== 0x20) {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error(`unexpected MQTT packet 0x${buf[0].toString(16)} instead of CONNACK`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = buf[3];
|
||||||
|
if (code === 0) socket.end(Buffer.from([0xe0, 0])); // DISCONNECT
|
||||||
|
else socket.destroy();
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -43,4 +43,9 @@ runProvisioner("mqtt-topic", {
|
|||||||
await mosquitto.deleteScopedClient(p.as);
|
await mosquitto.deleteScopedClient(p.as);
|
||||||
await announce("module.mosquitto.topic.deprovisioned", { username: p.as });
|
await announce("module.mosquitto.topic.deprovisioned", { username: p.as });
|
||||||
},
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return mosquitto.holdsClient(p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-4
@@ -75,14 +75,18 @@ export class MssqlClient {
|
|||||||
* prints (split across output lines for a large result, and reassembled here) is parsed. An
|
* prints (split across output lines for a large result, and reassembled here) is parsed. An
|
||||||
* empty result yields no output at all — an empty array.
|
* empty result yields no output at all — an empty array.
|
||||||
*/
|
*/
|
||||||
async query(select: string, database = "master"): Promise<Record<string, unknown>[]> {
|
async query(
|
||||||
|
select: string,
|
||||||
|
database = "master",
|
||||||
|
variables: Record<string, string> = {},
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
|
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
|
||||||
const stdout = await this.sqlcmd(wrapped, database);
|
const stdout = await this.sqlcmd(wrapped, database, variables);
|
||||||
return parseJsonRows(stdout);
|
return parseJsonRows(stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
||||||
private async sqlcmd(sql: string, database: string): Promise<string> {
|
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
|
||||||
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
|
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
|
||||||
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
|
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
|
||||||
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
|
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
|
||||||
@@ -101,7 +105,9 @@ export class MssqlClient {
|
|||||||
"-W",
|
"-W",
|
||||||
"-Q", sql,
|
"-Q", sql,
|
||||||
],
|
],
|
||||||
{ env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
|
||||||
|
// variables: a value that must not appear on argv, or in the message of a failed command.
|
||||||
|
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
||||||
);
|
);
|
||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
@@ -121,6 +127,9 @@ export class MssqlClient {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
|
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
|
||||||
|
// A disabled login is refused like a wrong password; the check the provisioner runs reports it
|
||||||
|
// lost, so applying again must enable it or the two would disagree for ever.
|
||||||
|
await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbs = await this.query(
|
const dbs = await this.query(
|
||||||
@@ -138,10 +147,51 @@ export class MssqlClient {
|
|||||||
);
|
);
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
|
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
|
||||||
|
} else {
|
||||||
|
// Re-point an existing user at the login when its SID is not the login's: a database restored
|
||||||
|
// from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that
|
||||||
|
// is already mapped is left alone.
|
||||||
|
const orphaned = await this.query(
|
||||||
|
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` +
|
||||||
|
`AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`,
|
||||||
|
database,
|
||||||
|
);
|
||||||
|
if (orphaned.length > 0) {
|
||||||
|
await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
|
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of
|
||||||
|
* `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so
|
||||||
|
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
|
||||||
|
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
|
||||||
|
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
|
||||||
|
// minted value, which carries no quote.
|
||||||
|
const server = await this.query(
|
||||||
|
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
|
||||||
|
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
|
||||||
|
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
|
||||||
|
"master",
|
||||||
|
{ MESHHOLDSPW: password },
|
||||||
|
);
|
||||||
|
if (Number(server[0]?.ok) !== 1) return false;
|
||||||
|
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
|
||||||
|
// right name and the wrong SID, and cannot be reached through the login.
|
||||||
|
const owner = await this.query(
|
||||||
|
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` +
|
||||||
|
`JOIN sys.server_principals sp ON dp.sid = sp.sid ` +
|
||||||
|
`WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` +
|
||||||
|
`AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`,
|
||||||
|
database,
|
||||||
|
);
|
||||||
|
return Number(owner[0]?.ok) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Drop a database and its login, idempotently, after evicting live connections. */
|
/** Drop a database and its login, idempotently, after evicting live connections. */
|
||||||
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
|
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
|
||||||
const dbs = await this.query(
|
const dbs = await this.query(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -44,4 +44,9 @@ runProvisioner("mssql-database", {
|
|||||||
await mssql.dropDatabaseAndLogin(p.as, p.as);
|
await mssql.dropDatabaseAndLogin(p.as, p.as);
|
||||||
await announce("module.mssql.database.deprovisioned", { database: p.as });
|
await announce("module.mssql.database.deprovisioned", { database: p.as });
|
||||||
},
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return mssql.holdsLogin(p.as, p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||||
ARG BUILD_BASE
|
ARG BUILD_BASE
|
||||||
ARG RUNTIME_BASE
|
ARG RUNTIME_BASE
|
||||||
|
ARG DOCKER_CLI
|
||||||
|
|
||||||
|
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
|
||||||
|
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
|
||||||
|
FROM ${DOCKER_CLI} AS dockercli
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
FROM ${BUILD_BASE} AS build
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||||
@@ -22,6 +27,11 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts
|
|||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
FROM ${RUNTIME_BASE}
|
||||||
COPY --from=build /app/modules/nextcloud/dist /app/modules/nextcloud/dist
|
COPY --from=build /app/modules/nextcloud/dist /app/modules/nextcloud/dist
|
||||||
|
# occ runs inside nextcloud's own container, reached over the mounted docker socket — which needs
|
||||||
|
# the docker CLI itself present here, not only the socket. Copied from Docker's own official client
|
||||||
|
# image rather than apt-installed, so this stays the one binary and nothing else (no daemon, no
|
||||||
|
# systemd unit, no package manager tree pulled in for it).
|
||||||
|
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
|
||||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||||
|
|||||||
@@ -52,8 +52,13 @@ export class NextcloudClient {
|
|||||||
const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
|
const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
|
||||||
const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
||||||
const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
|
const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
|
||||||
const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD;
|
const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE;
|
||||||
if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD");
|
const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD
|
||||||
|
?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined);
|
||||||
|
if (!adminPassword) {
|
||||||
|
throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE " +
|
||||||
|
"(or MESH_NEXTCLOUD_ADMIN_PASSWORD)");
|
||||||
|
}
|
||||||
return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword);
|
return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/nextcloud-module/server.env",
|
"path": "/var/lib/nextcloud-module/server.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=nextcloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\n"
|
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=nextcloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "html",
|
"id": "html",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "nextcloud",
|
"name": "nextcloud",
|
||||||
"image": "nextcloud@sha256:0b8261f6335af6b95264ce893b4d645857638e0fa151b5ba620f25f377318ae1",
|
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/nextcloud-module/server.env"
|
"/var/lib/nextcloud-module/server.env"
|
||||||
],
|
],
|
||||||
@@ -106,12 +106,15 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
|
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
|
||||||
|
"/var/lib/nextcloud-module/admin.secret:/run/secrets/admin:ro",
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:80",
|
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}",
|
||||||
"MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json"
|
"MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json",
|
||||||
|
"MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin",
|
||||||
|
"MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "/run/secrets/admin"
|
||||||
},
|
},
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"runtime-config"
|
"runtime-config"
|
||||||
@@ -130,6 +133,10 @@
|
|||||||
"arg": "RUNTIME_BASE",
|
"arg": "RUNTIME_BASE",
|
||||||
"module": "mesh-tools",
|
"module": "mesh-tools",
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "DOCKER_CLI",
|
||||||
|
"image": "docker@sha256:018edbc908e08fcc9dbf029c812c34251e9b4719e6f71ca0e5eae2a987d014ca"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"/var/lib/only-office/server.env"
|
"/var/lib/only-office/server.env"
|
||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
"80"
|
"9070:80"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/only-office/logs:/var/log/onlyoffice",
|
"/services/only-office/logs:/var/log/onlyoffice",
|
||||||
|
|||||||
@@ -6,11 +6,17 @@
|
|||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
|
{
|
||||||
|
"port": 9090,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"port": 9443,
|
"port": 9443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the container dashboard, over its own tls"
|
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -23,19 +29,20 @@
|
|||||||
{
|
{
|
||||||
"id": "data",
|
"id": "data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/services/portainer/data",
|
"path": "/services/portainer/portainer_data",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "portainer",
|
"name": "portainer",
|
||||||
"image": "portainer/portainer-ce@sha256:511f3f06c96fe3b993ebeaafde311c1959cae73a7ef825dba6397d51b450dffa",
|
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
|
||||||
"ports": [
|
"ports": [
|
||||||
"9443"
|
"9090:9000",
|
||||||
|
"9443:9443"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/portainer/data:/data",
|
"/services/portainer/portainer_data:/data",
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -90,5 +97,17 @@
|
|||||||
"from": "Dockerfile"
|
"from": "Dockerfile"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"requires": [
|
||||||
|
"route"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"route": {
|
||||||
|
"label": "portainer",
|
||||||
|
"port": 9090
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"binds": {
|
||||||
|
"route": "/var/lib/mesh/portainer/route.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ export class PostgresClient {
|
|||||||
async createDatabaseAndRole(database: string, role: string, password: string): Promise<void> {
|
async createDatabaseAndRole(database: string, role: string, password: string): Promise<void> {
|
||||||
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
|
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
|
||||||
if (roles.rows.length === 0) {
|
if (roles.rows.length === 0) {
|
||||||
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
|
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
|
||||||
} else {
|
} else {
|
||||||
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
|
// VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the
|
||||||
|
// provisioner runs would report it lost, and only clearing the expiry makes applying it again work.
|
||||||
|
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
|
||||||
}
|
}
|
||||||
const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database));
|
const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database));
|
||||||
if (dbs.rows.length === 0) {
|
if (dbs.rows.length === 0) {
|
||||||
@@ -99,6 +101,30 @@ export class PostgresClient {
|
|||||||
await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`);
|
await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its
|
||||||
|
* credential, checked by connecting as it. Read-only. `false` only when the server says so (the
|
||||||
|
* role, the password or the database is wrong or gone); an unreachable server rejects instead,
|
||||||
|
* because being unable to ask is not evidence of loss (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async canConnectAs(database: string, role: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await run(
|
||||||
|
"psql",
|
||||||
|
["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database,
|
||||||
|
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"],
|
||||||
|
{ env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const text = `${(err as { stderr?: string }).stderr ?? ""}`;
|
||||||
|
if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Drop a database and its owning role, idempotently, after evicting live connections. */
|
/** Drop a database and its owning role, idempotently, after evicting live connections. */
|
||||||
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
|
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
|
||||||
await this.query(
|
await this.query(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -45,4 +45,9 @@ runProvisioner("postgres-database", {
|
|||||||
await postgres.dropDatabaseAndRole(p.as, p.as);
|
await postgres.dropDatabaseAndRole(p.as, p.as);
|
||||||
await announce("module.postgres.database.deprovisioned", { database: p.as });
|
await announce("module.postgres.database.deprovisioned", { database: p.as });
|
||||||
},
|
},
|
||||||
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return postgres.canConnectAs(p.as, p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"at": "acme-v02.api.letsencrypt.org",
|
"at": "acme-v02.api.letsencrypt.org",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"path": "/directory",
|
"path": "/directory",
|
||||||
"root": ""
|
"roots": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -8,7 +8,7 @@
|
|||||||
// order requests were sent, which is what the queue below relies on.
|
// order requests were sent, which is what the queue below relies on.
|
||||||
|
|
||||||
import { createConnection, type Socket } from "node:net";
|
import { createConnection, type Socket } from "node:net";
|
||||||
import { randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */
|
/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */
|
||||||
@@ -113,6 +113,27 @@ export class RedisClient {
|
|||||||
await this.command("ACL", "DELUSER", username);
|
await this.command("ACL", "DELUSER", username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks
|
||||||
|
* `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among
|
||||||
|
* them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its
|
||||||
|
* users live in memory and a restart forgets them. This is how the provisioner notices
|
||||||
|
* (novox/hq issue 120).
|
||||||
|
*/
|
||||||
|
async holdsAclUser(username: string, password: string): Promise<boolean> {
|
||||||
|
const reply = await this.command("ACL", "GETUSER", username);
|
||||||
|
if (!Array.isArray(reply)) return false;
|
||||||
|
const field = (name: string): RespValue | undefined => {
|
||||||
|
const i = reply.indexOf(name);
|
||||||
|
return i >= 0 ? reply[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const flags = field("flags");
|
||||||
|
const passwords = field("passwords");
|
||||||
|
if (!Array.isArray(flags) || !flags.includes("on")) return false;
|
||||||
|
if (!Array.isArray(passwords)) return false;
|
||||||
|
return passwords.includes(createHash("sha256").update(password).digest("hex"));
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.destroy();
|
this.socket.destroy();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -43,4 +43,11 @@ runProvisioner("redis-cache", {
|
|||||||
await redis.deleteAclUser(p.as);
|
await redis.deleteAclUser(p.as);
|
||||||
await announce("module.redis.cache.deprovisioned", { username: p.as });
|
await announce("module.redis.cache.deprovisioned", { username: p.as });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// This server keeps its ACL users in memory only, so a restart of it forgets every consumer while
|
||||||
|
// this provisioner keeps running. Asked every minute, so a forgotten user is made again instead
|
||||||
|
// of every consumer failing to authenticate in silence (novox/hq issue 120).
|
||||||
|
async holds(p: Provision): Promise<boolean> {
|
||||||
|
return redis.holdsAclUser(p.as, p.password);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[
|
|||||||
skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`);
|
skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// What this adapter's one file shape cannot say, it skips aloud rather than approximating:
|
||||||
|
// a backend over its own TLS (the file would send plain http into a TLS listener), a
|
||||||
|
// path-scoped or refusing or redirecting rule (the file routes whole hosts). The mesh's own
|
||||||
|
// proxy serves all of these the day it takes over; until then the predecessor's hand-authored
|
||||||
|
// files keep covering them, exactly as they do today.
|
||||||
|
const scheme = typeof entry.values?.["scheme"] === "string" ? (entry.values["scheme"] as string).trim().toLowerCase() : "";
|
||||||
|
if (scheme !== "" && scheme !== "http") {
|
||||||
|
skipped.push(`${from} asked for route ${name} over ${scheme}, which this file shape cannot say`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof entry.values?.["path"] === "string" && (entry.values["path"] as string).trim() !== "") {
|
||||||
|
skipped.push(`${from} asked for route ${name} scoped to a path, which this file shape cannot say`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.values?.["deny"] === true || typeof entry.values?.["redirect"] === "string") {
|
||||||
|
skipped.push(`${from} asked for route ${name} with a policy this file shape cannot say`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const port = asPort(entry.values?.["port"]);
|
const port = asPort(entry.values?.["port"]);
|
||||||
if (port === undefined) {
|
if (port === undefined) {
|
||||||
skipped.push(`${from} asked for route ${name} and gave no usable port`);
|
skipped.push(`${from} asked for route ${name} and gave no usable port`);
|
||||||
|
|||||||
@@ -205,6 +205,27 @@ test("a contribution it cannot act on is skipped and named", async () => {
|
|||||||
assert.deepEqual(routesFrom(undefined, machine).routes, []);
|
assert.deepEqual(routesFrom(undefined, machine).routes, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What the file shape cannot say is skipped aloud, never approximated: plain http into a TLS
|
||||||
|
// listener, a whole-host file for a path-scoped rule, a proxying file for a refusal or redirect.
|
||||||
|
// The mesh's own proxy serves all of these the day it takes over; until then the predecessor's
|
||||||
|
// hand-authored files keep covering them.
|
||||||
|
test("a contribution the file shape cannot say is skipped and says which part", async () => {
|
||||||
|
const machine = defaults.machine;
|
||||||
|
const { routes, skipped } = routesFrom({ given: [
|
||||||
|
{ from: "mailu", values: { name: "mail.example", port: 7443, scheme: "https", insecure: true } },
|
||||||
|
{ from: "mailu", values: { name: "mail.example", port: 7080, path: "/.well-known/acme-challenge" } },
|
||||||
|
{ from: "gitea", values: { name: "git.example", path: "/api/internal", deny: true, priority: 100000 } },
|
||||||
|
{ from: "site", values: { name: "www.example", redirect: "https://example" } },
|
||||||
|
{ from: "mailu", values: { name: "autoconfig.example", port: 4243 } },
|
||||||
|
] }, machine);
|
||||||
|
assert.deepEqual(routes.map((r) => r.name), ["autoconfig.example"]);
|
||||||
|
assert.equal(skipped.length, 4);
|
||||||
|
assert.match(skipped[0]!, /over https/);
|
||||||
|
assert.match(skipped[1]!, /scoped to a path/);
|
||||||
|
assert.match(skipped[2]!, /scoped to a path/);
|
||||||
|
assert.match(skipped[3]!, /policy/);
|
||||||
|
});
|
||||||
|
|
||||||
// The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write
|
// The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write
|
||||||
// into — and writing anyway would put route files somewhere nothing reads, reporting success.
|
// into — and writing anyway would put route files somewhere nothing reads, reporting success.
|
||||||
test("it refuses when the predecessor's directory is not there, and says why", async () => {
|
test("it refuses when the predecessor's directory is not there, and says why", async () => {
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
"route": "/var/lib/route-proxy/routes/mesh.json"
|
"route": "/var/lib/route-proxy/routes/mesh.json"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
"acme-ca"
|
"acme-ca",
|
||||||
|
"internal-acme-ca"
|
||||||
],
|
],
|
||||||
"binds": {
|
"binds": {
|
||||||
"acme-ca": "/var/lib/route-proxy/acme-ca.json"
|
"acme-ca": "/var/lib/route-proxy/acme-ca.json",
|
||||||
|
"internal-acme-ca": "/var/lib/route-proxy/internal-acme-ca.json"
|
||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
@@ -67,7 +69,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/route-proxy/acme.env",
|
"path": "/var/lib/route-proxy/acme.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\n"
|
"content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "internal-acme-env",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/var/lib/route-proxy/internal-acme.env",
|
||||||
|
"mode": "0600",
|
||||||
|
"content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trust",
|
"id": "trust",
|
||||||
@@ -85,20 +94,43 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"sh",
|
"sh",
|
||||||
"-c",
|
"-c",
|
||||||
"for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1"
|
"if [ -z \"$ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1"
|
||||||
],
|
],
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"acme-env"
|
"acme-env"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "internal-trust",
|
||||||
|
"type": "container",
|
||||||
|
"name": "route-proxy-internal-trust",
|
||||||
|
"artifact": "trust",
|
||||||
|
"run-once": true,
|
||||||
|
"network": "host",
|
||||||
|
"env-file": [
|
||||||
|
"/var/lib/route-proxy/internal-acme.env"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/route-proxy/ca:/ca"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"if [ -z \"$INTERNAL_ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/internal-root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/internal-root.crt \"$INTERNAL_ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/internal-root.crt && exit 0; sleep 2; done; echo \"the authority at $INTERNAL_ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1"
|
||||||
|
],
|
||||||
|
"restart-on": [
|
||||||
|
"internal-acme-env"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "route-proxy",
|
"name": "route-proxy",
|
||||||
"image": "mesh-route-proxy@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
"artifact": "server",
|
||||||
"network": "host",
|
"network": "host",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/route-proxy/acme.env"
|
"/var/lib/route-proxy/acme.env",
|
||||||
|
"/var/lib/route-proxy/internal-acme.env"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/route-proxy/routes:/routes:ro",
|
"/var/lib/route-proxy/routes:/routes:ro",
|
||||||
@@ -110,11 +142,14 @@
|
|||||||
"LISTEN": ":80",
|
"LISTEN": ":80",
|
||||||
"TLS_LISTEN": ":443",
|
"TLS_LISTEN": ":443",
|
||||||
"ACME_CACHE": "/acme",
|
"ACME_CACHE": "/acme",
|
||||||
"ACME_CA_BUNDLE": "/ca/root.crt"
|
"ACME_CA_BUNDLE": "/ca/root.crt",
|
||||||
|
"INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt"
|
||||||
},
|
},
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"trust",
|
"trust",
|
||||||
"acme-env"
|
"acme-env",
|
||||||
|
"internal-trust",
|
||||||
|
"internal-acme-env"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -123,7 +158,11 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"kind": "image",
|
"kind": "image",
|
||||||
"from": "Dockerfile"
|
"from": "Dockerfile",
|
||||||
|
"context": {
|
||||||
|
"repository": "https://git.novox.be/novox/mesh-controller.git",
|
||||||
|
"ref": "main"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "trust",
|
"name": "trust",
|
||||||
|
|||||||
@@ -8,12 +8,20 @@
|
|||||||
{
|
{
|
||||||
"name": "acme-ca",
|
"name": "acme-ca",
|
||||||
"scope": "mesh"
|
"scope": "mesh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "internal-acme-ca",
|
||||||
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"serves": {
|
"serves": {
|
||||||
"acme-ca": {
|
"acme-ca": {
|
||||||
"path": "/acme/acme/directory",
|
"path": "/acme/acme/directory",
|
||||||
"roots": "/roots.pem"
|
"roots": "/roots.pem"
|
||||||
|
},
|
||||||
|
"internal-acme-ca": {
|
||||||
|
"path": "/acme/acme/directory",
|
||||||
|
"roots": "/roots.pem"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
},
|
},
|
||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
"name": "package-registry",
|
"name": "npm-package-registry",
|
||||||
"scope": "mesh"
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user