From 123c4ed24975409f9c27693c53446602c08aaff4 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 30 Oct 2026 08:11:24 +0100 Subject: [PATCH] feat(dawarich): keep managed Dawarich off the shared Docker network Dawarich fetches URLs its users choose (geofencing webhooks, Immich and PhotoPrism imports and the thumbnail proxy) and, in self-hosted mode, accepts private addresses. Move its four containers onto private networks: one for the bundle and internet access, one through which only Traefik and app-api reach the web app. A new first-party manifest capability, privateNetworks with platformNetwork: false, renders them. --- .changeset/private-bundle-networks.md | 6 ++ apps/api/openapi.json | 11 +++ docs/docs/developer/service-manifest.md | 37 +++++++ docs/self-hosting/dawarich.md | 52 +++++++--- .../services-compose-renderer.test.ts | 64 ++++++++++++ .../src/__tests__/services-end-to-end.test.ts | 38 ++++++++ .../getAdminService200Manifest.ts | 6 ++ .../core/src/services/compose-renderer.ts | 97 ++++++++++++++++--- .../extension-contracts/schemas/service.json | 11 +++ .../extension-contracts/src/service-policy.ts | 19 ++++ .../extension-contracts/src/service-types.ts | 4 + packages/extension-contracts/src/service.ts | 8 ++ release-notes/managed-dawarich-1-19.md | 4 +- services/app-api/service.json | 1 + services/dawarich-app/service.json | 2 + services/dawarich-postgis/service.json | 2 + services/dawarich-redis/service.json | 2 + services/dawarich-sidekiq/service.json | 2 + services/traefik/service.json | 1 + 19 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 .changeset/private-bundle-networks.md diff --git a/.changeset/private-bundle-networks.md b/.changeset/private-bundle-networks.md new file mode 100644 index 000000000..f30e86b5d --- /dev/null +++ b/.changeset/private-bundle-networks.md @@ -0,0 +1,6 @@ +--- +"@openmapx/extension-contracts": minor +"@openmapx/extension-cli": patch +--- + +Accept `privateNetworks` and `platformNetwork` on a service manifest: a first-party bundle can join named `openmapx-private-` networks and leave the shared `openmapx` network. Community services cannot declare either field, and neither combines with host networking. Rebuild the CLI against the updated portable contracts. diff --git a/apps/api/openapi.json b/apps/api/openapi.json index efca4f78d..10aad9112 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -44313,6 +44313,17 @@ "pattern": "^[a-z0-9][a-z0-9-]*$" } }, + "privateNetworks": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$" + } + }, + "platformNetwork": { + "type": "boolean" + }, "configSchema": { "type": "object", "properties": { diff --git a/docs/docs/developer/service-manifest.md b/docs/docs/developer/service-manifest.md index 074b9c3e8..e17e1b95d 100644 --- a/docs/docs/developer/service-manifest.md +++ b/docs/docs/developer/service-manifest.md @@ -143,6 +143,8 @@ The top-level descriptive fields. | `quality` | enum | yes | `built-in`, `community-verified`, or `community`. Display/catalog label only; the sandbox uses where the manifest was loaded from (see [Quality tiers](#quality-tiers)). | | `platform` | string | no | Descriptive service field. Extension bundle and integration manifests enforce the separate `major.minor` protocol requirement; this service schema accepts a string. | | `communityNetworkAccess` | service id[] | no | Audited first-party capability only. Explicitly attaches a built-in bridge service to the named community services' isolated networks while retaining `openmapx`. Rejected from community provenance at validation and render boundaries. Start with no entries; this is not a platform-network escape hatch. | +| `privateNetworks` | name[] | no | Audited first-party capability only. Names reviewed private networks (`openmapx-private-`) the service joins, so a multi-container bundle can keep its traffic off `openmapx`. See [Private bundle networks](#private-bundle-networks). | +| `platformNetwork` | boolean | no | Audited first-party capability only. `false` keeps the service off `openmapx`; it then needs at least one `privateNetworks` entry. Defaults to joining `openmapx`. | ## `container` @@ -219,6 +221,40 @@ If multiple community containers need to discover each other, define an explicit reviewed multi-component service bundle instead of widening platform-network access. +### Private bundle networks + +A first-party bundle whose containers act on user-supplied URLs can leave the +shared network. Members declare `"platformNetwork": false` and the private +networks they share, for example `"privateNetworks": ["dawarich"]`. The +renderer attaches them only to `openmapx-private-` networks, so they +cannot resolve or connect to `openmapx` peers. They keep outbound internet +access through those bridges. Each private network is dual-stack like +`openmapx`, with a stable IPv6 /64 derived from its name inside +`fd4d:5058::/48`. + +A built-in that stays on `openmapx` and lists the same private network is a +bridge: Traefik routes to a bundle's web container this way, and app-api calls +it. A bridge joins the network only while an enabled member that left +`openmapx` declares it. A default install without the bundle therefore renders +no extra network. As with community bridges, the network carries traffic both +ways: bundle containers on it can open connections to the bridge. Give that +network only the members that must be reachable, and keep workers on a +separate private network without bridges. + +The managed Dawarich bundle is the shipped example: + +| Network | Members | +| ----------------------------------- | ------------------------------------------------------------------------ | +| `openmapx-private-dawarich` | `dawarich-app`, `dawarich-sidekiq`, `dawarich-postgis`, `dawarich-redis` | +| `openmapx-private-dawarich-ingress` | `dawarich-app`, plus the bridges `traefik` and `app-api` | + +The worker, which delivers geofencing webhooks and runs photo imports, can +reach only its own database, Redis, the web app and the internet. The web app, +whose photo-thumbnail proxy fetches a user-configured URL and returns the +response body, can additionally reach Traefik's entrypoints and app-api. On +app-api that includes the unauthenticated `/api/internal/metrics` counters, +which Traefik otherwise denies publicly. + ### `container.healthcheck` The renderer translates this into a docker-compose `healthcheck` block. `http` @@ -707,6 +743,7 @@ The schema enforces, among other rules: - First-party `exposure.proxy.enabled: true` requires at least one `container.expose` port; community services cannot enable the platform proxy. - Duplicate `(type, instance)` pairs in `produces` are rejected. - `communityNetworkAccess` is first-party-only and names community service ids (uninstalled or disabled targets are skipped); community services never join `openmapx`, including when a manifest is handed directly to the renderer. +- `privateNetworks` and `platformNetwork` are first-party-only and cannot be combined with host networking. Private network names are DNS-label-shaped; `platformNetwork: false` requires at least one private network. - Third-party services may not use bind mounts, host networking, privileged mode, host device pass-through, escape-class capabilities, deployment `envFile`, Compose-variable bind paths, platform proxy routes, or deployment-variable interpolation in Compose command, entrypoint, environment, and exec health-check fields. Their volume names and network aliases are isolated from platform-owned names. Capability and data-type strings that are neither well-known nor namespaced diff --git a/docs/self-hosting/dawarich.md b/docs/self-hosting/dawarich.md index d97b4a40f..ff6fbbe15 100644 --- a/docs/self-hosting/dawarich.md +++ b/docs/self-hosting/dawarich.md @@ -237,17 +237,47 @@ so queued jobs survive the update. The privacy collectors accept only the 1.19.0 schema. A backup taken on 1.15.3 records the `dawarich-1.15.3` contract, and the backup subject export refuses it. -## Geofencing webhooks and network reach - -Dawarich 1.18 lets each user send geofence arrival and departure events to a -webhook URL. The managed app runs with `SELF_HOSTED=true`, in which Dawarich -accepts private addresses and Docker service names, as it already does for the -Immich and PhotoPrism integration URLs; it blocks only cloud-metadata, multicast -and reserved ranges. Dawarich containers share the `openmapx` Docker network, so -a Dawarich user can make the worker send a signed POST with a fixed JSON body to -another OpenMapX service and read its status code and error message. Dawarich -stores, but does not show, the response body. Review this before onboarding users -you would not give that reach, and keep internal endpoints authenticated. +The same update moves the four Dawarich containers off the `openmapx` network +(see [Network isolation](#network-isolation-and-user-supplied-urls)). Compose +recreates them and adds Traefik and app-api to the ingress network; volumes +are untouched. If you added your own service that calls `dawarich-app` or the +Dawarich database by name over `openmapx`, it can no longer reach them. + +## Network isolation and user-supplied URLs + +Dawarich fetches several URLs its users choose. Since 1.18 the worker sends +geofence arrival and departure events to each user's webhook. The worker also +runs the Immich and PhotoPrism imports, and the web app proxies their photo +thumbnails. The managed app runs with `SELF_HOSTED=true`. Dawarich then accepts +private addresses and Docker service names and blocks only cloud-metadata, +multicast and reserved ranges. + +The managed containers therefore never join the shared `openmapx` network. +They use two private networks: + +| Network | Members | Why | +| ----------------------------------- | ------------------------------------------------- | -------------------------------------------- | +| `openmapx-private-dawarich` | app, worker, `dawarich-postgis`, `dawarich-redis` | The bundle's own traffic and internet access | +| `openmapx-private-dawarich-ingress` | app, `traefik`, `app-api` | Public routing and OpenMapX's timeline reads | + +So a Dawarich user cannot point a webhook or photo URL at OpenMapX's database, +Redis, data manager, ops agent or other platform services. Those names don't +resolve from the Dawarich containers, and Docker drops traffic to their +addresses. Outbound internet access is unchanged. + +Two kinds of reach remain: + +- **Inside the bundle.** A webhook can target the bundle's own Postgres, Redis + or web app. Postgres requires the bundle's password. Redis drops connections + that look like HTTP, and the web app checks each request's credentials. +- **From the web app to Traefik and app-api.** Traefik serves only its public + entrypoints. On app-api, the thumbnail proxy could fetch an unauthenticated + route and show its body to the user. Besides the public API, that means the + `/api/internal/metrics` counters, which contain no personal data. + +If you replace the bundled proxy with your own (`OPENMAPX_PROXY_NETWORK`), the +web app also joins that proxy's network. Keep that network free of services +you would not expose to Dawarich users. ## Disable and purge diff --git a/packages/core/src/__tests__/services-compose-renderer.test.ts b/packages/core/src/__tests__/services-compose-renderer.test.ts index a561e5e26..997b71ce9 100644 --- a/packages/core/src/__tests__/services-compose-renderer.test.ts +++ b/packages/core/src/__tests__/services-compose-renderer.test.ts @@ -315,6 +315,70 @@ describe("renderServiceSnippet", () => { expect(() => renderCompose([bridge, svc("redis")], {})).toThrow(/redis.*built-in/); }); + it("keeps a private bundle off openmapx and bridges only its declared peers", () => { + const bundle = { platformNetwork: false, privateNetworks: ["bundle"] }; + const { composeYaml } = renderCompose( + [ + svc("bundle-web", { + platformNetwork: false, + privateNetworks: ["bundle", "bundle-ingress"], + }), + svc("bundle-worker", bundle), + svc("bundle-db", { + ...bundle, + container: { image: "t/db", tag: "1", networkAliases: ["db"] }, + }), + svc("gateway", { privateNetworks: ["bundle-ingress"] }), + svc("redis"), + ], + {}, + ); + const doc = parseYaml(composeYaml) as { + services: Record; + networks: Record; + }; + + expect(doc.services["bundle-web"].networks).toEqual([ + "openmapx-private-bundle", + "openmapx-private-bundle-ingress", + ]); + expect(doc.services["bundle-worker"].networks).toEqual(["openmapx-private-bundle"]); + expect(doc.services["bundle-db"].networks).toEqual({ + "openmapx-private-bundle": { aliases: ["db"] }, + }); + expect(doc.services.gateway.networks).toEqual(["openmapx", "openmapx-private-bundle-ingress"]); + expect(doc.services.redis.networks).toEqual(["openmapx"]); + const bundleNet = doc.networks["openmapx-private-bundle"]; + const ingressNet = doc.networks["openmapx-private-bundle-ingress"]; + expect(bundleNet?.enable_ipv6).toBe(true); + expect(bundleNet?.ipam?.config[0]?.subnet).toMatch(/^fd4d:5058:0:[0-9a-f]{1,4}::\/64$/); + expect(bundleNet?.ipam?.config[0]?.subnet).not.toBe(ingressNet?.ipam?.config[0]?.subnet); + }); + + it("joins a bridge to a private network only while another enabled member declares it", () => { + const gateway = svc("gateway", { privateNetworks: ["bundle-ingress"] }); + const disabledWeb = { + ...svc("bundle-web", { platformNetwork: false, privateNetworks: ["bundle-ingress"] }), + enabled: false, + }; + const doc = parseYaml(renderCompose([gateway, disabledWeb], {}).composeYaml) as { + services: Record; + networks: Record; + }; + + expect(doc.services.gateway.networks).toEqual(["openmapx"]); + expect(doc.networks).not.toHaveProperty("openmapx-private-bundle-ingress"); + }); + + it("rejects private networks on a community service and a bundle member without one", () => { + expect(() => + renderServiceSnippet(communitySvc("community-bundle", { privateNetworks: ["bundle"] }), {}), + ).toThrow(/private_network_forbidden/); + expect(() => renderServiceSnippet(svc("orphan", { platformNetwork: false }), {})).toThrow( + /requires at least one privateNetworks/, + ); + }); + it("fails closed when different community ids normalize to the same network", () => { expect(() => renderCompose([communitySvc("weather.feed"), communitySvc("weather-feed")], {}), diff --git a/packages/core/src/__tests__/services-end-to-end.test.ts b/packages/core/src/__tests__/services-end-to-end.test.ts index 305fa9dde..fbea0110e 100644 --- a/packages/core/src/__tests__/services-end-to-end.test.ts +++ b/packages/core/src/__tests__/services-end-to-end.test.ts @@ -518,6 +518,44 @@ describe.skipIf(!manifestsPresent)( ); }); + it("keeps every Dawarich container off openmapx and lets only Traefik and app-api reach the app", async () => { + const registry = new ServiceRegistry({ rootDir: repoRoot }); + await registry.load(); + const render = (dawarich: boolean) => { + const services = registry.list().map((service) => ({ + ...service, + enabled: service.manifest.id.startsWith("dawarich-") + ? dawarich + : ["traefik", "app-api", "postgis", "redis"].includes(service.manifest.id), + })); + return parseYaml(renderCompose(services, { domain: "example.com" }).composeYaml) as { + services: Record; + networks: Record; + }; + }; + + const doc = render(true); + expect(doc.services["dawarich-app"]?.networks).toEqual([ + "openmapx-private-dawarich", + "openmapx-private-dawarich-ingress", + ]); + for (const id of ["dawarich-sidekiq", "dawarich-postgis", "dawarich-redis"]) { + expect(doc.services[id]?.networks).toEqual(["openmapx-private-dawarich"]); + } + for (const id of ["traefik", "app-api"]) { + expect(doc.services[id]?.networks).toContain("openmapx-private-dawarich-ingress"); + expect(doc.services[id]?.networks).not.toContain("openmapx-private-dawarich"); + } + for (const id of ["postgis", "redis"]) { + expect(doc.services[id]?.networks).toEqual(["openmapx"]); + } + + const withoutDawarich = render(false); + expect(withoutDawarich.services.traefik?.networks).toEqual(["openmapx"]); + expect(withoutDawarich.services["app-api"]?.networks).toEqual(["openmapx"]); + expect(Object.keys(withoutDawarich.networks)).toEqual(["openmapx"]); + }); + it("renders the pinned private data services, shared app data, file-only secrets and host-only TLS route", async () => { const registry = new ServiceRegistry({ rootDir: repoRoot }); await registry.load(); diff --git a/packages/core/src/api/generated/admin/service-models/getAdminService200Manifest.ts b/packages/core/src/api/generated/admin/service-models/getAdminService200Manifest.ts index ec511a8e6..209b56fd5 100644 --- a/packages/core/src/api/generated/admin/service-models/getAdminService200Manifest.ts +++ b/packages/core/src/api/generated/admin/service-models/getAdminService200Manifest.ts @@ -39,6 +39,12 @@ export type GetAdminService200Manifest = { selectionDependencies?: string[]; /** @items.pattern ^[a-z0-9][a-z0-9-]*$ */ communityNetworkAccess?: string[]; + /** + * @minItems 1 + * @items.pattern ^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$ + */ + privateNetworks?: string[]; + platformNetwork?: boolean; configSchema?: GetAdminService200ManifestConfigSchema; envVars?: GetAdminService200ManifestEnvVarsItem[]; /** @pattern ^[a-z_][a-z0-9_]*$ */ diff --git a/packages/core/src/services/compose-renderer.ts b/packages/core/src/services/compose-renderer.ts index 8bc78182e..edf4c936b 100644 --- a/packages/core/src/services/compose-renderer.ts +++ b/packages/core/src/services/compose-renderer.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import { dump as yamlDump } from "js-yaml"; @@ -58,6 +59,46 @@ function communityNetworkName(serviceId: string): string { return name; } +const PRIVATE_NETWORK_PREFIX = "openmapx-private-"; + +function privateNetworkName(name: string): string { + return `${PRIVATE_NETWORK_PREFIX}${name}`; +} + +/** + * The private networks a built-in joins. A bridge (a service that stays on + * `openmapx`) joins only while an enabled bundle member, one that left + * `openmapx`, declares the network, so a default install without that bundle + * renders no stray network. + */ +function joinedPrivateNetworks(service: LoadedService, allServices: LoadedService[]): string[] { + const m = service.manifest; + return (m.privateNetworks ?? []) + .filter( + (name) => + m.platformNetwork === false || + allServices.some( + (other) => + other.isBuiltIn && + other.enabled && + other.manifest.platformNetwork === false && + other.manifest.privateNetworks?.includes(name), + ), + ) + .map(privateNetworkName); +} + +/** + * A stable IPv6 ULA /64 per private network, inside the `openmapx` network's + * fd4d:5058::/48 but never its fd4d:5058::/64, so members keep the same + * dual-stack egress as the shared network without daemon address pools. + */ +function privateNetworkSubnet(network: string): string { + const digest = createHash("sha256").update(network).digest(); + const block = digest.readUInt16BE(0) || 1; + return `fd4d:5058:0:${block.toString(16)}::/64`; +} + function assertUniqueCommunityNetworkNames(services: LoadedService[]): void { const owners = new Map(); for (const service of services) { @@ -623,10 +664,13 @@ export function renderServiceSnippet( snippet.networks = c.networkAliases?.length ? { [network]: { aliases: [...c.networkAliases] } } : [network]; - } else if (communityNetworkAccess?.length) { - const networks = ["openmapx"]; + } else { const allServices = ctx.allServices ?? [service]; - for (const targetId of communityNetworkAccess) { + const networks = [ + ...(m.platformNetwork === false ? [] : ["openmapx"]), + ...joinedPrivateNetworks(service, allServices), + ]; + for (const targetId of communityNetworkAccess ?? []) { const target = allServices.find((candidate) => candidate.manifest.id === targetId); if (target?.isBuiltIn) { throw new Error( @@ -640,19 +684,20 @@ export function renderServiceSnippet( if (!target || !target.enabled) continue; networks.push(communityNetworkName(targetId)); } + // Docker Compose long-form: `networks: { openmapx: { aliases: [...] } }`. + // Aliases go on the service's home network: `openmapx`, or its private + // networks when it stays off the shared one. + const aliasNetworks = networks.includes("openmapx") + ? ["openmapx"] + : networks.filter((network) => network.startsWith(PRIVATE_NETWORK_PREFIX)); snippet.networks = c.networkAliases?.length - ? Object.fromEntries([ - ["openmapx", { aliases: [...c.networkAliases] }], - ...networks.slice(1).map((network) => [network, {}]), - ]) + ? Object.fromEntries( + networks.map((network) => [ + network, + aliasNetworks.includes(network) ? { aliases: [...(c.networkAliases ?? [])] } : {}, + ]), + ) : networks; - } else if (c.networkAliases?.length) { - // Docker Compose long-form: `networks: { openmapx: { aliases: [...] } }`. - // Other containers on the openmapx network can address this service via - // any listed alias in addition to the service id. - snippet.networks = { openmapx: { aliases: [...c.networkAliases] } }; - } else { - snippet.networks = ["openmapx"]; } // Behind an operator-run proxy, a routed first-party service also joins that @@ -1036,6 +1081,29 @@ export function renderCompose(services: LoadedService[], ctx: RenderContext): Re .filter((service) => service.enabled && !service.isBuiltIn) .map((service) => [communityNetworkName(service.manifest.id), { driver: "bridge" }]), ); + const privateNetworks: Record = {}; + const privateSubnetOwners = new Map(); + for (const snippet of Object.values(composeServices)) { + const joined = Array.isArray(snippet.networks) + ? snippet.networks + : Object.keys(snippet.networks ?? {}); + for (const network of joined) { + if (!network.startsWith(PRIVATE_NETWORK_PREFIX) || network in privateNetworks) continue; + const subnet = privateNetworkSubnet(network); + const existing = privateSubnetOwners.get(subnet); + if (existing) { + throw new Error( + `Private networks "${existing}" and "${network}" derive the same IPv6 subnet ${subnet}`, + ); + } + privateSubnetOwners.set(subnet, network); + privateNetworks[network] = { + driver: "bridge", + enable_ipv6: true, + ipam: { config: [{ subnet }] }, + }; + } + } const proxyNetwork = externalProxyNetwork(ctx); const joinsProxyNetwork = Object.values(composeServices).some( (snippet) => @@ -1065,6 +1133,7 @@ export function renderCompose(services: LoadedService[], ctx: RenderContext): Re enable_ipv6: true, ipam: { config: [{ subnet: "fd4d:5058::/64" }] }, }, + ...privateNetworks, ...communityNetworks, ...externalNetworks, }, diff --git a/packages/extension-contracts/schemas/service.json b/packages/extension-contracts/schemas/service.json index 5f7d13b62..8b6f872ae 100644 --- a/packages/extension-contracts/schemas/service.json +++ b/packages/extension-contracts/schemas/service.json @@ -367,6 +367,17 @@ "pattern": "^[a-z0-9][a-z0-9-]*$" } }, + "privateNetworks": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$" + } + }, + "platformNetwork": { + "type": "boolean" + }, "configSchema": { "type": "object", "properties": { diff --git a/packages/extension-contracts/src/service-policy.ts b/packages/extension-contracts/src/service-policy.ts index 3d0c59352..ab417d462 100644 --- a/packages/extension-contracts/src/service-policy.ts +++ b/packages/extension-contracts/src/service-policy.ts @@ -57,6 +57,25 @@ export function checkManifestSandbox(m: ServiceManifest, provenance: ManifestPro "community_network_access_forbidden: communityNetworkAccess is reserved for audited first-party services", ); } + if ( + !hostPrivilegesAllowed && + (m.privateNetworks !== undefined || m.platformNetwork !== undefined) + ) { + errors.push( + "private_network_forbidden: privateNetworks and platformNetwork are reserved for audited first-party services", + ); + } + if (m.platformNetwork === false && !m.privateNetworks?.length) { + errors.push("platformNetwork: false requires at least one privateNetworks entry"); + } + if ( + m.container.networkMode === "host" && + (m.privateNetworks !== undefined || m.platformNetwork !== undefined) + ) { + errors.push( + "container.networkMode: 'host' cannot be combined with privateNetworks or platformNetwork", + ); + } if (!hostPrivilegesAllowed && m.container.privileged) { errors.push("container.privileged is not allowed for community services"); } diff --git a/packages/extension-contracts/src/service-types.ts b/packages/extension-contracts/src/service-types.ts index c18eef33d..01d05bb27 100644 --- a/packages/extension-contracts/src/service-types.ts +++ b/packages/extension-contracts/src/service-types.ts @@ -325,6 +325,10 @@ export interface ServiceManifest { selectionDependencies?: string[]; /** Audited first-party bridge access to explicitly named community networks. */ communityNetworkAccess?: string[]; + /** Audited first-party private networks (`openmapx-private-`) the service joins. */ + privateNetworks?: string[]; + /** First-party only: `false` keeps the service off the shared `openmapx` network. */ + platformNetwork?: boolean; configSchema?: Record; envVars?: Array<{ name: string; required?: boolean; description?: string; default?: string }>; diff --git a/packages/extension-contracts/src/service.ts b/packages/extension-contracts/src/service.ts index d22566707..03a82569a 100644 --- a/packages/extension-contracts/src/service.ts +++ b/packages/extension-contracts/src/service.ts @@ -14,6 +14,8 @@ const VOLUME_NAME_REGEX = /^openmapx-[a-z0-9-]+$/; const ABSOLUTE_PATH_REGEX = /^\/[^\s]+$/; const PATH_PREFIX_REGEX = /^\/[a-zA-Z0-9._\-/]*$/; const SERVICE_ID_REGEX = /^[a-z0-9][a-z0-9-]*$/; +// A DNS-label-shaped name; the renderer prefixes it to `openmapx-private-`. +const PRIVATE_NETWORK_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/; const CONFIG_PROPERTY_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; /** PostgreSQL role/database names passed as individual docker-compose argv values. */ export const POSTGRES_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_$]*$/; @@ -473,6 +475,12 @@ export const serviceManifestSchema = z.object({ .array(z.string().regex(SERVICE_ID_REGEX, "must be a service id")) .refine((ids) => new Set(ids).size === ids.length, "must not contain duplicates") .optional(), + privateNetworks: z + .array(z.string().regex(PRIVATE_NETWORK_REGEX, "must be a private network name")) + .min(1) + .refine((names) => new Set(names).size === names.length, "must not contain duplicates") + .optional(), + platformNetwork: z.boolean().optional(), configSchema: configSchema.optional(), envVars: z.array(envVarSchema).optional(), diff --git a/release-notes/managed-dawarich-1-19.md b/release-notes/managed-dawarich-1-19.md index b53ed1973..9b6cf2260 100644 --- a/release-notes/managed-dawarich-1-19.md +++ b/release-notes/managed-dawarich-1-19.md @@ -8,7 +8,7 @@ The managed Dawarich bundle moves from Dawarich 1.15.3 to 1.19.0, and its dedica On first start, Dawarich migrates its database and then runs long background jobs: it turns Areas into Places, repairs tracks from the last 12 months, recalculates flight months and digests, and detects visits again across each user's whole history. Rolling back does not undo these jobs. Redis 8 reads the data Redis 7.4 saved, so queued jobs carry over. -Dawarich 1.18 adds geofencing webhooks. A user can make Dawarich send arrival and departure events to a URL of their choice. Because the managed bundle runs Dawarich in self-hosted mode, those URLs may point at private addresses, including other services on the OpenMapX Docker network. The user sees only the response status and error, never the response body. Dawarich already allowed private targets for its Immich and PhotoPrism integrations. +Dawarich 1.18 adds geofencing webhooks: a user can make Dawarich send arrival and departure events to a URL of their choice. Because the managed bundle runs Dawarich in self-hosted mode, such URLs, like the Immich and PhotoPrism URLs before them, may name private addresses. The managed Dawarich containers therefore leave the shared `openmapx` Docker network. They run on two private networks: one for the app, worker, database and Redis, and one through which only Traefik and the OpenMapX API reach the web app. Webhooks and photo URLs can no longer reach OpenMapX's database, Redis or other platform services. Outbound internet access is unchanged. The privacy subject export now covers geofence events, webhooks and their delivery state, legacy area-to-place mappings and point/track revision counters. Webhook signing secrets, the path and query of webhook URLs, and webhook response bodies and error messages are never exported. Backups now record the `dawarich-1.19.0` schema contract; the backup subject export refuses a backup taken on 1.15.3. @@ -20,4 +20,4 @@ Installations that run the managed Dawarich bundle. External Dawarich instances Create a backup that includes the managed Dawarich state before updating, as Dawarich asks for 1.18 and 1.19. Keep the Dawarich worker running after the update until its background jobs finish; on a small server, setting `SKIP_VISITS_FLEET_REDETECT=1` defers the full-history visit detection, which a user can start later from Visits → Re-run detection on full history. -If Dawarich users are not fully trusted with access to the OpenMapX Docker network, review the geofencing webhook note above before onboarding them. +If you added your own service that reaches `dawarich-app` or the Dawarich database by name over the `openmapx` network, move it to a supported path: the private networks admit only the bundle, Traefik and the OpenMapX API. diff --git a/services/app-api/service.json b/services/app-api/service.json index c69ffd91f..18d7e2b49 100644 --- a/services/app-api/service.json +++ b/services/app-api/service.json @@ -5,6 +5,7 @@ "description": "Fastify BFF gateway. Proxies external services and registers integrations.", "license": "MIT", "quality": "built-in", + "privateNetworks": ["dawarich-ingress"], "communityNetworkAccess": ["openconditions-ingest", "openconditions-contributions-api"], "subjectData": { "storesPersonalData": true, diff --git a/services/dawarich-app/service.json b/services/dawarich-app/service.json index 7a0151369..f26a11a97 100644 --- a/services/dawarich-app/service.json +++ b/services/dawarich-app/service.json @@ -5,6 +5,8 @@ "description": "Managed multi-user Dawarich web application with OpenMapX OIDC sign-in.", "license": "AGPL-3.0", "quality": "built-in", + "platformNetwork": false, + "privateNetworks": ["dawarich", "dawarich-ingress"], "homepage": "https://dawarich.app/", "documentation": "https://dawarich.app/docs/self-hosting/", "subjectData": { diff --git a/services/dawarich-postgis/service.json b/services/dawarich-postgis/service.json index 3814b1952..d71a5ac4e 100644 --- a/services/dawarich-postgis/service.json +++ b/services/dawarich-postgis/service.json @@ -5,6 +5,8 @@ "description": "Dedicated PostgreSQL 17 and PostGIS 3.5 database for the managed Dawarich timeline.", "license": "PostgreSQL", "quality": "built-in", + "platformNetwork": false, + "privateNetworks": ["dawarich"], "homepage": "https://github.com/baosystems/docker-postgis", "subjectData": { "storesPersonalData": true, diff --git a/services/dawarich-redis/service.json b/services/dawarich-redis/service.json index 156060e3d..4e5cfb43e 100644 --- a/services/dawarich-redis/service.json +++ b/services/dawarich-redis/service.json @@ -5,6 +5,8 @@ "description": "Dedicated transient job queue and cache for the managed Dawarich timeline.", "license": "RSALv2 OR SSPL-1.0 OR AGPL-3.0", "quality": "built-in", + "platformNetwork": false, + "privateNetworks": ["dawarich"], "homepage": "https://redis.io/", "subjectData": { "storesPersonalData": true, diff --git a/services/dawarich-sidekiq/service.json b/services/dawarich-sidekiq/service.json index 9ec6b37f9..5d1682f40 100644 --- a/services/dawarich-sidekiq/service.json +++ b/services/dawarich-sidekiq/service.json @@ -5,6 +5,8 @@ "description": "Background job worker for the managed multi-user Dawarich timeline.", "license": "AGPL-3.0", "quality": "built-in", + "platformNetwork": false, + "privateNetworks": ["dawarich"], "homepage": "https://dawarich.app/", "documentation": "https://dawarich.app/docs/self-hosting/", "subjectData": { diff --git a/services/traefik/service.json b/services/traefik/service.json index 3a0567ef3..9041d8372 100644 --- a/services/traefik/service.json +++ b/services/traefik/service.json @@ -5,6 +5,7 @@ "description": "Reverse proxy and TLS termination via Let's Encrypt.", "license": "MIT", "quality": "built-in", + "privateNetworks": ["dawarich-ingress"], "homepage": "https://traefik.io/", "subjectData": { "storesPersonalData": false,