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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/private-bundle-networks.md
Original file line number Diff line number Diff line change
@@ -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-<name>` 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.
11 changes: 11 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions docs/docs/developer/service-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>`) 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`

Expand Down Expand Up @@ -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-<name>` 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`
Expand Down Expand Up @@ -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
Expand Down
52 changes: 41 additions & 11 deletions docs/self-hosting/dawarich.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/__tests__/services-compose-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { networks: unknown }>;
networks: Record<string, { enable_ipv6?: boolean; ipam?: { config: { subnet: string }[] } }>;
};

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<string, { networks: unknown }>;
networks: Record<string, unknown>;
};

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")], {}),
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/__tests__/services-end-to-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { networks: string[] }>;
networks: Record<string, unknown>;
};
};

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_]*$ */
Expand Down
Loading
Loading