feat(deploy): collab-relay production configs, CN docker firewall, HSM locator

Add production deployment configs for the collab relay and locator: region-split
compose (CN/global), direct nginx gateways and location maps, the CN docker-user
firewall install/verify/validate tooling and systemd unit, and an SoftHSM-backed
locator variant. Extend the collab security/deployment boundary checks to cover
the new artifacts.

The CN application host address is a placeholder (10.0.0.10); substitute the real
private address at deploy time.
This commit is contained in:
Kayshen-X 2026-08-11 21:46:47 +08:00
parent fbcc1d0dc6
commit a39406c12b
40 changed files with 2673 additions and 136 deletions

View file

@ -16,6 +16,7 @@ on:
- 'crates/op-collab-relay-server/**'
- 'crates/op-collab-relay-control-plane/**'
- 'crates/op-collab-policy-file/**'
- 'crates/op-collab-relay-locator-hsm/**'
- 'crates/op-collab-relay-locator-server/**'
- 'crates/op-auth-bridge/**'
- 'crates/op-util/**'
@ -29,6 +30,7 @@ on:
- 'deploy/collab-relay/**'
- 'deploy/collab-relay-edge/**'
- 'deploy/collab-relay-locator/**'
- 'deploy/collab-relay-locator-hsm/**'
- 'deploy/collab-relay-locator-edge/**'
- 'docs/security/p2p-collaboration-threat-model.md'
- 'docs/testing/p2p-collaboration-platform-acceptance.md'
@ -57,6 +59,7 @@ on:
- 'crates/op-collab-relay-server/**'
- 'crates/op-collab-relay-control-plane/**'
- 'crates/op-collab-policy-file/**'
- 'crates/op-collab-relay-locator-hsm/**'
- 'crates/op-collab-relay-locator-server/**'
- 'crates/op-auth-bridge/**'
- 'crates/op-util/**'
@ -70,6 +73,7 @@ on:
- 'deploy/collab-relay/**'
- 'deploy/collab-relay-edge/**'
- 'deploy/collab-relay-locator/**'
- 'deploy/collab-relay-locator-hsm/**'
- 'deploy/collab-relay-locator-edge/**'
- 'docs/security/p2p-collaboration-threat-model.md'
- 'docs/testing/p2p-collaboration-platform-acceptance.md'
@ -146,8 +150,14 @@ jobs:
cargo test --locked -p op-collab-relay-server
cargo test --locked -p op-collab-relay-control-plane
cargo test --locked -p op-collab-policy-file
cargo test --locked -p op-collab-relay-locator-hsm
cargo test --locked -p op-collab-relay-locator-server
- name: Test the locator signer against SoftHSM
run: |
docker build --target test \
-f deploy/collab-relay-locator-hsm/Dockerfile .
- name: Test production/test trust-root isolation
run: |
cargo test --locked -p op-auth-bridge --features test-issuer \

View file

@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.7
ARG DOCKER_REGISTRY=docker.io
FROM ${DOCKER_REGISTRY}/library/rust:1.94-bookworm@sha256:6ae102bdbf528294bc79ad6e1fae682f6f7c2a6e6621506ba959f9685b308a55 AS build
WORKDIR /src
COPY . .
RUN cargo build --locked --release -p op-collab-relay-locator-hsm
FROM build AS test
RUN apt-get update && \
apt-get install -y --no-install-recommends softhsm2 && \
rm -rf /var/lib/apt/lists/*
ENV OPENPENCIL_SOFTHSM2_MODULE=/usr/lib/softhsm/libsofthsm2.so
RUN cargo test --locked -p op-collab-relay-locator-hsm --test softhsm -- --nocapture
FROM ${DOCKER_REGISTRY}/library/debian:bookworm-20260713-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818
ARG LOCATOR_UID=65532
ARG SIGNER_UID=65533
ARG SHARED_GID=65532
RUN apt-get update && \
apt-get install -y --no-install-recommends softhsm2 && \
rm -rf /var/lib/apt/lists/* && \
groupadd --gid "${SHARED_GID}" openpencil-locator && \
useradd --uid "${SIGNER_UID}" --gid "${SHARED_GID}" --no-create-home \
--home-dir /nonexistent --shell /usr/sbin/nologin openpencil-locator-hsm && \
test "${LOCATOR_UID}" != "${SIGNER_UID}" && \
install -d -o "${SIGNER_UID}" -g "${SHARED_GID}" -m 0700 /var/lib/openpencil-softhsm/tokens
COPY --from=build /src/target/release/op-collab-relay-locator-hsm /usr/local/bin/op-collab-relay-locator-hsm
COPY --chmod=0444 deploy/collab-relay-locator-hsm/softhsm2.conf /etc/openpencil-softhsm2.conf
ENV SOFTHSM2_CONF=/etc/openpencil-softhsm2.conf
USER ${SIGNER_UID}:${SHARED_GID}
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/op-collab-relay-locator-hsm", "check", "--config", "/run/openpencil-config/locator-hsm.json"]
ENTRYPOINT ["/usr/local/bin/op-collab-relay-locator-hsm"]
CMD ["serve", "--config", "/run/openpencil-config/locator-hsm.json"]

View file

@ -0,0 +1,185 @@
# OpenPencil locator SoftHSM signer
This package is the production OPLS Unix-socket adapter for the relay locator.
It is a separate non-root container with no network namespace. Only this
container receives the SoftHSM token directory and user PIN. The locator sees
only `signer.sock` read-only; it cannot read the token store, PIN, or private
key.
The adapter accepts exactly one 339-byte OPLS v1 request per connection,
authenticates the locator UID/GID with Unix peer credentials, requires the
configured active key id, and rejects bytes that are not a canonical locator
for the configured region. This confines the dedicated Ed25519 key to the
locator protocol without changing the existing 268-byte signature input. Each
accepted request uses single-part PKCS#11 EdDSA signing and the returned
signature is verified against the token public key before release.
## Key profile
Configuration contains exactly two public entries: active and next. Object ids
and labels must be unique. Provisioning refuses to overwrite either. Startup
requires one public and one private object for each entry and verifies, among
other attributes:
- `CKK_EC_EDWARDS`, Ed25519 parameters, and locally generated key material;
- private `CKA_SENSITIVE=true`, `CKA_EXTRACTABLE=false`,
`CKA_ALWAYS_SENSITIVE=true`, and `CKA_NEVER_EXTRACTABLE=true`;
- private sign-only and public verify-only use with the Ed25519 key type;
- token persistence, exact labels/object ids, and no duplicate pair objects.
This deployment profile intentionally limits public key ids and token labels
to ASCII letters, digits, `-`, `_`, and `.`. Migrate any older printable wire
ids containing other punctuation before using this adapter.
Neither `initialize` nor `provision` exports private bytes. `public` emits the
two public Ed25519 records accepted by the relay key-file parser. The signed
desktop bootstrap uses the same base64url public value under its `x` field.
## Host preparation
Use separate directories, token labels, key ids, and object ids in CN and
Global. Concrete public hosts and generated key ids belong in the private
deployment inventory, not in this repository. The numeric identities used by
the checked-in images are locator UID/GID `65532:65532` and signer
`65533:65532`.
Prepare external host paths on each regional Linux host:
```sh
sudo install -d -o 65533 -g 65532 -m 0700 /secure/openpencil/locator-hsm/tokens
sudo install -o 65533 -g 65532 -m 0400 /dev/null /secure/openpencil/locator-hsm/user-pin
sudo install -o 65533 -g 65532 -m 0400 /dev/null /secure/openpencil/locator-hsm/so-pin
sudo install -o root -g 65532 -m 0440 \
deploy/collab-relay-locator-hsm/config.example.json \
/secure/openpencil/locator-hsm/config.json
sudo install -o root -g root -m 0644 \
deploy/collab-relay-locator-hsm/openpencil-locator-hsm.conf \
/etc/tmpfiles.d/openpencil-locator-hsm.conf
sudo systemd-tmpfiles --create /etc/tmpfiles.d/openpencil-locator-hsm.conf
```
Replace the example region, token label, active/next public key ids, and unique
object ids. Populate both PIN files through the operator secret channel; do not
put a PIN on a command line, in Compose environment, or in shell history.
Docker file-backed mounts preserve host ownership rather than secret `uid` or
`mode` declarations, so verify these numeric modes on the target host.
The checked-in `tmpfiles.d` rule recreates the volatile `/run` socket directory
as `root:65532` mode `0770` after every boot. Install it before Compose; never
let Docker auto-create a missing bind source as `root:root` mode `0755`.
Set the Compose inputs:
```sh
export OPENPENCIL_COLLAB_HSM_CONFIG_HOST_FILE=/secure/openpencil/locator-hsm/config.json
export OPENPENCIL_COLLAB_HSM_PIN_HOST_FILE=/secure/openpencil/locator-hsm/user-pin
export OPENPENCIL_COLLAB_HSM_TOKEN_HOST_DIR=/secure/openpencil/locator-hsm/tokens
export OPENPENCIL_COLLAB_HSM_SOCKET_HOST_DIR=/run/openpencil/locator-hsm
```
## Initialize and provision
Build the image, then initialize the empty regional token. The SO PIN is an
extra one-shot mount and is not part of the running service:
```sh
docker compose \
-f deploy/collab-relay-locator-hsm/compose.yaml \
build locator-hsm
docker compose \
-f deploy/collab-relay-locator-hsm/compose.yaml \
run --rm \
-v /secure/openpencil/locator-hsm/so-pin:/run/secrets/locator-hsm-so-pin:ro \
locator-hsm initialize --config /run/openpencil-config/locator-hsm.json \
--so-pin-file /run/secrets/locator-hsm-so-pin
```
Provision the two configured key ids one at a time. This makes a partial
failure explicit and never silently reuses or replaces an existing object:
```sh
docker compose -f deploy/collab-relay-locator-hsm/compose.yaml \
run --rm locator-hsm provision \
--config /run/openpencil-config/locator-hsm.json --kid replace-with-active-public-kid
docker compose -f deploy/collab-relay-locator-hsm/compose.yaml \
run --rm locator-hsm provision \
--config /run/openpencil-config/locator-hsm.json --kid replace-with-next-public-kid
```
Export public records to a candidate file, validate it, then distribute that
same active+next set to the relay and signed desktop bootstrap before starting
issuance:
```sh
docker compose -f deploy/collab-relay-locator-hsm/compose.yaml \
run --rm locator-hsm public \
--config /run/openpencil-config/locator-hsm.json > /secure/public/locator-keys.candidate.json
```
Remove the SO PIN mount/file from routine operations after its recovery copy
has been placed in the operator-controlled secret system.
## Start, readiness, and rotation
Start the signer and locator together only after the public policy and key
files are ready:
```sh
export OPENPENCIL_COLLAB_POLICY_HOST_FILE=/secure/public/collab-policy.json
export OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID=replace-with-active-public-kid
export OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID=65533
export OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID=65532
docker compose \
-f deploy/collab-relay-locator-hsm/compose.yaml \
-f deploy/collab-relay-locator/compose.yaml \
-f deploy/collab-relay-locator/compose.production.global.yaml \
-f deploy/collab-relay-locator/compose.hsm.yaml \
up --build -d
```
That command is for Global. On CN, replace only the regional overlay with
`compose.production.cn.yaml`. These overlays hard-code the matching home
region and host bind; there is no host-bind variable to override.
Startup and `check` validate both key pairs and perform a locator-shaped HSM
sign-and-software-verify canary with each private key. The container health
check additionally requires the Unix socket and a fresh heartbeat written by
the main serving loop. The locator waits for a healthy signer.
For rotation from A(active)+B(next), first publish A+B everywhere. Prepare a
candidate config with B(active)+C(next), mount it into a one-shot signer,
provision C, and export the B+C candidate. Before promoting, merge the previous
and candidate public files into an A+B+C overlap verifier bundle; the relay
accepts up to 64 pinned keys. For example:
```sh
jq -s '{version: 1, keys: ([.[].keys[]] | unique_by(.kid))}' \
/secure/public/locator-keys.current.json \
/secure/public/locator-keys.candidate.json \
> /secure/public/locator-keys.overlap.json
```
Distribute A+B+C to the relay and signed desktop bootstrap, atomically install
the B+C signer config, and restart signer plus locator. Retain A in verifier
bundles for the maximum locator lifetime and rollback window; only then publish
B+C alone. The signer's `public` command emits its configured pair and does not
perform this verifier-history merge. This adapter intentionally has no
key-destruction command; retirement is a separate audited token-admin
operation.
For every start or promotion,
`OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID` on the locator must exactly equal the
signer config's `active_kid`. Exercise one authenticated locator issuance and
verify the returned signature against the newly distributed public key before
opening traffic.
## Verification
The Docker test target runs the integration test against a real SoftHSM module:
```sh
docker build --target test \
-f deploy/collab-relay-locator-hsm/Dockerfile .
docker build -f deploy/collab-relay-locator-hsm/Dockerfile .
```

View file

@ -0,0 +1,33 @@
services:
locator-hsm:
build:
context: ../..
dockerfile: deploy/collab-relay-locator-hsm/Dockerfile
restart: unless-stopped
network_mode: none
environment:
OPENPENCIL_LOCATOR_HSM_LOG: ${OPENPENCIL_LOCATOR_HSM_LOG:-info}
volumes:
- type: bind
source: ${OPENPENCIL_COLLAB_HSM_CONFIG_HOST_FILE:?set absolute public signer config file}
target: /run/openpencil-config/locator-hsm.json
read_only: true
- type: bind
source: ${OPENPENCIL_COLLAB_HSM_PIN_HOST_FILE:?set absolute signer PIN file}
target: /run/secrets/locator-hsm-pin
read_only: true
- type: bind
source: ${OPENPENCIL_COLLAB_HSM_TOKEN_HOST_DIR:?set absolute SoftHSM token directory}
target: /var/lib/openpencil-softhsm/tokens
- type: bind
source: ${OPENPENCIL_COLLAB_HSM_SOCKET_HOST_DIR:?set absolute HSM socket directory}
target: /run/openpencil-hsm
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 64
mem_limit: 128m
tmpfs:
- /tmp:size=8m,mode=1700,noexec,nosuid,nodev

View file

@ -0,0 +1,22 @@
{
"version": 1,
"region": "global",
"socket_path": "/run/openpencil-hsm/signer.sock",
"expected_client_uid": 65532,
"expected_client_gid": 65532,
"pkcs11_module_path": "/usr/lib/softhsm/libsofthsm2.so",
"token_label": "openpencil-locator-global",
"pin_file": "/run/secrets/locator-hsm-pin",
"active_kid": "replace-with-active-public-kid",
"keys": [
{
"kid": "replace-with-active-public-kid",
"object_id_hex": "7101"
},
{
"kid": "replace-with-next-public-kid",
"object_id_hex": "7102"
}
],
"request_timeout_ms": 2000
}

View file

@ -0,0 +1,2 @@
# systemd-tmpfiles rule for the volatile shared Unix-socket directory.
d /run/openpencil/locator-hsm 0770 root 65532 -

View file

@ -0,0 +1,4 @@
directories.tokendir = /var/lib/openpencil-softhsm/tokens/
objectstore.backend = file
log.level = ERROR
slots.removable = false

View file

@ -13,8 +13,13 @@ network. Terminate public TLS at a trusted ingress, include
`nginx-http-limits.conf` once in its `http` block, and include
`nginx-location.conf` in the exact-host TLS server. The checked-in zones
enforce per-source request and connection limits before the process-wide
aggregate limiter. If a load balancer sits in front, restore client addresses
only from its fixed trusted addresses; never trust a public
aggregate limiter. Docker NAT collapses clients crossing the host-published
port into one locator-visible peer address. The immutable regional overlays
therefore set `OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND=100`, equal to
the global locator ceiling, while the common Compose file sets no production
override. Host Nginx is the trusted real-source boundary and must retain the
checked-in per-source zones. If a load balancer sits in front, restore client
addresses only from its fixed trusted addresses; never trust a public
`X-Forwarded-For`. The ingress checks the raw `$request_uri`, so queries and
percent-encoded path aliases cannot be normalized into the route, and it
requires a real client `Host` header instead of Nginx's fallback host. Only
@ -24,7 +29,6 @@ exposes no configuration or dependency status.
The server enforces:
- at most 32 headers and a 16 KiB HTTP header buffer;
- at most 32 headers and a 64 KiB HTTP header buffer, enough for the bounded
48 KiB collaboration-ticket envelope;
- a five-second header timeout and body timeout;
@ -68,9 +72,10 @@ response (70 bytes, then EOF)
Status `0` is success and `1` is rejection. Unknown status, zero signatures,
truncation, trailing bytes, timeout, peer mismatch, and socket replacement all
fail closed. The HSM-side adapter is deployment-owned; it must authorize the
configured key id and keep the Ed25519 private key inside the HSM/KMS boundary.
There is intentionally no software production signer in this repository.
fail closed. The isolated SoftHSM adapter in
[`deploy/collab-relay-locator-hsm`](../collab-relay-locator-hsm/README.md)
implements this boundary. It authorizes only the configured active key id and
keeps the Ed25519 private key and token PIN outside this container.
## Configuration
@ -93,9 +98,19 @@ The compose wrapper instead requires these host mount inputs:
- `OPENPENCIL_COLLAB_HSM_SOCKET_HOST_DIR`, an absolute host directory
containing `signer.sock`.
On Unix, the public signed-policy file may be owned either by the locator's
effective UID or by root. It must be a regular non-symlink file and must not be
group- or world-writable. Root-owned mode `0440` (when the container identity
has group read) or `0444` is accepted because the policy contains public
verification material; writable modes fail closed.
The compose file mounts policy and socket inputs read-only, drops all Linux
capabilities, uses the distroless non-root identity, enables a read-only root
filesystem, and does not publish port 8092 to the host.
filesystem, and publishes no host port. The immutable Global production
overlay sets `home_region=global` and binds `127.0.0.1:8092`; the immutable CN
overlay sets `home_region=cn` and binds `10.0.0.10:8092`. There is no
host-bind variable or wildcard default. Restrict the CN service-host firewall
to the front gateway.
Both Dockerfile base images are pinned by multi-architecture manifest digest,
verified from their upstream registries on 2026-07-29. Base-image updates must
@ -109,7 +124,11 @@ Optional runtime settings are
`OPENPENCIL_COLLAB_LOCATOR_POLICY_MAX_AGE_SECONDS` (`1..=3600`),
`OPENPENCIL_COLLAB_LOCATOR_HSM_TIMEOUT_MS` (`50..=5000`),
`OPENPENCIL_COLLAB_LOCATOR_MAX_AUTH_IN_FLIGHT` (`1..=256`), and
`OPENPENCIL_COLLAB_LOCATOR_RATE_PER_SECOND` (`1..=10000`). The optional
`OPENPENCIL_COLLAB_LOCATOR_RATE_PER_SECOND` (`1..=10000`).
`OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND` (`1..=10000`) is also
optional outside the immutable regional production overlays; those overlays
fix it at `100` because Docker NAT removes usable client identity at the
locator socket. The optional
`OPENPENCIL_COLLAB_LOCATOR_LOG_LEVEL` accepts only `error`, `warn`, `info`, or
`debug` and is scoped to this crate; arbitrary dependency trace filters are
not accepted, so HTTP headers cannot be enabled through logging configuration.
@ -120,14 +139,106 @@ Validate the fail-closed deployment invariants and resolved compose model:
deploy/collab-relay-locator/validate.sh
```
Start after provisioning the public policy file and external signer socket:
Start after provisioning the public policy file and signer token:
```sh
docker compose -f deploy/collab-relay-locator/compose.yaml up --build
export OPENPENCIL_COLLAB_POLICY_HOST_FILE=/secure/public/collab-policy.json
export OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID=replace-with-active-public-kid
export OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID=65533
export OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID=65532
docker compose \
-f deploy/collab-relay-locator-hsm/compose.yaml \
-f deploy/collab-relay-locator/compose.yaml \
-f deploy/collab-relay-locator/compose.production.global.yaml \
-f deploy/collab-relay-locator/compose.hsm.yaml \
up --build
```
That command is for Global. On CN, replace only the regional overlay with
`compose.production.cn.yaml`. The base file alone supplies neither a home
region nor a host port, and the two regional overlays must never be combined.
For a CN home session, deploy this service and the blind relay in the CN
region. Overseas owners/guests call the CN locator/relay endpoints over
TLS/WSS. A global edge may proxy the opaque TLS/WSS flows to the CN home
services, but it must not mint a different region or replace signed endpoint
policy.
region. Any user who selects CN calls the CN application's `/v1/locator` and
`/v1/tunnel` paths directly over TLS/WSS. Global home sessions use the same
two paths on the Global application host. Concrete hosts stay in private
deployment inventory and signed bootstrap metadata; this direct topology does
not require a Global L4 edge. The two Compose projects have independent Docker
networks: Global host Nginx reaches their loopback-published ports, while the
CN front gateway reaches both ports on `10.0.0.10`. Host Nginx must not use
the Compose-only `locator` or `relay` DNS names.
## Persistent CN service-host firewall
The CN host-published relay and locator ports require the checked-in
`DOCKER-USER` boundary. Its private inputs are deployment inventory and must
not be committed. Copy `cn-docker-user-firewall.env.example` to a root-owned,
non-symlink inventory path under root-owned, non-writable directories, replace
the RFC 5737 examples, and keep the file exactly `root:root` mode `0600`:
```sh
sudo install -d -o root -g root -m 0700 /etc/openpencil/inventory
sudo install -o root -g root -m 0600 \
deploy/collab-relay-locator/cn-docker-user-firewall.env.example \
/etc/openpencil/inventory/collab-cn-firewall.env
sudoedit /etc/openpencil/inventory/collab-cn-firewall.env
sudo deploy/collab-relay-locator/install-cn-docker-user-firewall.sh \
/etc/openpencil/inventory/collab-cn-firewall.env
```
The inventory accepts exactly three unquoted data values: the existing Linux
ingress interface, the one gateway source IPv4, and the service host's
original-destination IPv4. Hostnames, CIDRs, shell syntax, leading-zero IPv4
octets, unknown keys, duplicates, symlinks, non-root ownership, and permissive
modes fail closed. The inventory is parsed as data and is never sourced.
Installation also requires that the service IPv4 exactly equals the single
numeric `host_ip` in both immutable CN production overlays: relay TCP `8091`
and locator TCP `8092`. The derived value is retained separately as a
root-owned mode `0400` deployment binding. Every apply and verify then requires
that exact IPv4 to be assigned exactly once on the configured live interface.
The installer atomically replaces `/etc/openpencil/collab-cn-firewall.env`,
installs immutable root-owned helpers, enables a pre-Docker systemd gate, and
adds a Docker drop-in that reapplies and verifies the rules after every daemon
start. When Docker is already active, installation calls the immutable apply
and verify helpers directly and proves its active state and main PID did not
change; it does not start, stop, or restart either unit. During the next
maintenance restart, require both units to remain healthy:
```sh
sudo systemctl restart docker.service
sudo systemctl status --no-pager \
openpencil-collab-cn-firewall.service docker.service
sudo /usr/local/libexec/openpencil-collab-cn-firewall/verify-cn-docker-user-firewall.sh
```
The atomic `iptables-restore --noflush` transaction places one dedicated chain
at the start of `DOCKER-USER`. For each original-destination TCP port `8091`
and `8092`, it returns only traffic arriving on the configured interface from
the configured source IPv4, then drops every other source to that exact
original destination. The final rule returns all unrelated traffic, so ports
`18080`, `18770`, SSH, and every unrelated host rule remain authoritative and
untouched. The read-only verifier checks the exact rules, their order and
count from one filter-table snapshot, the first-position jumps, the strict
inventory, the immutable Compose binding, and the live interface assignment
without changing kernel state. Apply refuses to flush a pre-existing chain of
the managed name unless the canonical anchor or exact prior five-rule shape
proves ownership; any foreign jump or goto reference from `FORWARD`, `INPUT`,
or another custom chain also fails closed before mutation.
This boundary requires Docker's iptables-compatible `FORWARD` to
`DOCKER-USER` hook. A Docker native-nftables configuration that omits that
hook, a missing interface, or another firewall manager rewriting the managed
chain makes the post-start verifier fail and therefore makes Docker activation
fail. Resolve that host integration before starting the CN Compose projects;
do not bypass the unit or weaken the verifier. This package assumes exclusive
ownership of its dedicated chain. A privileged firewall reload after Docker is
already active is outside the lifecycle hooks and must either invoke the apply
and verify helpers or be prohibited by host policy.
After starting both CN Compose projects, run the read-only verifier again,
inspect the managed-chain counters, and probe both paths once from the approved
gateway and once from a disallowed source. This live acceptance check proves
that the host is actually publishing the reviewed immutable overlays; the
installer deliberately does not start containers or infer permission to
deploy them.

View file

@ -0,0 +1,182 @@
#!/bin/sh
set -eu
LC_ALL=C
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL PATH
runtime_dir=/usr/local/libexec/openpencil-collab-cn-firewall
config_file=/etc/openpencil/collab-cn-firewall.env
binding_file=/etc/openpencil/collab-cn-firewall-compose.env
chain_name=OPENPENCIL-CN-INGRESS
anchor_comment=openpencil-cn-ingress-v1
allow_relay_comment=openpencil-cn-allow-8091-v1
drop_relay_comment=openpencil-cn-drop-8091-v1
allow_locator_comment=openpencil-cn-allow-8092-v1
drop_locator_comment=openpencil-cn-drop-8092-v1
fallthrough_comment=openpencil-cn-fallthrough-v1
if [ "$#" -ne 0 ]; then
echo "usage: $0" >&2
exit 2
fi
if [ "$(id -u)" -ne 0 ]; then
echo "CN firewall: applying host rules requires root" >&2
exit 1
fi
for command_name in stat ip iptables iptables-save iptables-restore \
mktemp chmod flock awk
do
command -v "$command_name" >/dev/null 2>&1 || {
echo "CN firewall: required command is unavailable: $command_name" >&2
exit 1
}
done
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
common_file="$script_dir/cn-docker-user-firewall-common.sh"
reference_checker="$script_dir/check-cn-docker-user-firewall-references.awk"
address_checker="$script_dir/check-cn-docker-user-firewall-interface-address.awk"
managed_chain_checker="$script_dir/check-cn-docker-user-firewall-managed-chain.awk"
if [ "$script_dir" != "$runtime_dir" ] || [ -L "$script_dir" ] ||
[ "$(stat -c '%u:%g:%a' -- "$script_dir")" != '0:0:755' ] ||
[ -L "$0" ] || [ "$(stat -c '%u:%g:%a' -- "$0")" != '0:0:755' ] ||
[ ! -f "$common_file" ] || [ -L "$common_file" ] ||
[ "$(stat -c '%u:%g:%a' -- "$common_file")" != '0:0:644' ] ||
[ ! -f "$reference_checker" ] || [ -L "$reference_checker" ] ||
[ "$(stat -c '%u:%g:%a' -- "$reference_checker")" != '0:0:644' ] ||
[ ! -f "$address_checker" ] || [ -L "$address_checker" ] ||
[ "$(stat -c '%u:%g:%a' -- "$address_checker")" != '0:0:644' ] ||
[ ! -f "$managed_chain_checker" ] || [ -L "$managed_chain_checker" ] ||
[ "$(stat -c '%u:%g:%a' -- "$managed_chain_checker")" != '0:0:644' ]
then
echo "CN firewall: installed runtime must be immutable root-owned files" >&2
exit 1
fi
. "$common_file"
cn_firewall_load_config "$config_file" strict
cn_firewall_load_deployment_binding "$binding_file"
if ! ip link show dev "$OPENPENCIL_CN_INGRESS_INTERFACE" >/dev/null 2>&1; then
cn_firewall_fail "configured ingress interface does not exist"
exit 1
fi
cn_fw_interface_addresses=$(ip -4 -o addr show dev \
"$OPENPENCIL_CN_INGRESS_INTERFACE") || {
cn_firewall_fail "could not inspect configured ingress-interface addresses"
exit 1
}
cn_fw_service_assignments=$(printf '%s\n' "$cn_fw_interface_addresses" | awk \
-v expected_ipv4="$OPENPENCIL_CN_SERVICE_IPV4" -f "$address_checker")
if [ "$cn_fw_service_assignments" -ne 1 ]; then
cn_firewall_fail \
"configured service IPv4 must be assigned exactly once to the ingress interface"
exit 1
fi
case $(iptables --version) in
*nf_tables*) cn_fw_iptables_backend=nft ;;
*legacy*) cn_fw_iptables_backend=legacy ;;
*) cn_firewall_fail "unrecognized iptables backend"; exit 1 ;;
esac
case $(iptables-restore --version) in
*nf_tables*) cn_fw_restore_backend=nft ;;
*legacy*) cn_fw_restore_backend=legacy ;;
*) cn_firewall_fail "unrecognized iptables-restore backend"; exit 1 ;;
esac
if [ "$cn_fw_iptables_backend" != "$cn_fw_restore_backend" ]; then
cn_firewall_fail "iptables and iptables-restore backends differ"
exit 1
fi
exec 9>/run/openpencil-collab-cn-firewall.lock
flock -x 9
filter_snapshot=$(iptables-save -t filter)
if printf '%s\n' "$filter_snapshot" |
grep -F ":$chain_name " >/dev/null
then
cn_fw_chain_exists=1
else
cn_fw_chain_exists=0
fi
if printf '%s\n' "$filter_snapshot" |
grep -F ':DOCKER-USER ' >/dev/null
then
cn_fw_docker_user_exists=1
else
cn_fw_docker_user_exists=0
fi
set -- $(printf '%s\n' "$filter_snapshot" | awk \
-v managed_chain="$chain_name" -v anchor_comment="$anchor_comment" \
-f "$reference_checker")
cn_fw_target_references=$1
cn_fw_canonical_jumps=$2
cn_fw_foreign_references=$4
if [ "$cn_fw_target_references" -ne "$cn_fw_canonical_jumps" ] ||
[ "$cn_fw_foreign_references" -ne 0 ]
then
cn_firewall_fail \
"foreign jump or goto reference into the managed chain is forbidden"
exit 1
fi
cn_fw_managed_summary=$(printf '%s\n' "$filter_snapshot" | awk \
-v managed_chain="$chain_name" \
-v ingress_interface="$OPENPENCIL_CN_INGRESS_INTERFACE" \
-v gateway_ipv4="$OPENPENCIL_CN_GATEWAY_SOURCE_IPV4" \
-v service_ipv4="$OPENPENCIL_CN_SERVICE_IPV4" \
-v allow_relay_comment="$allow_relay_comment" \
-v drop_relay_comment="$drop_relay_comment" \
-v allow_locator_comment="$allow_locator_comment" \
-v drop_locator_comment="$drop_locator_comment" \
-v fallthrough_comment="$fallthrough_comment" \
-f "$managed_chain_checker")
if [ "$cn_fw_chain_exists" -eq 1 ] && [ "$cn_fw_canonical_jumps" -eq 0 ] &&
[ "$cn_fw_managed_summary" != '5 5 5 0' ]
then
cn_firewall_fail \
"existing unanchored managed-chain name is not an exact prior OpenPencil chain"
exit 1
fi
umask 077
staged=$(mktemp /run/openpencil-collab-cn-firewall.XXXXXX)
cleanup() {
rm -f "$staged"
}
trap cleanup EXIT HUP INT TERM
chmod 0600 "$staged"
{
printf '%s\n' '*filter'
if [ "$cn_fw_docker_user_exists" -eq 0 ]; then
printf '%s\n' ':DOCKER-USER - [0:0]'
fi
if [ "$cn_fw_chain_exists" -eq 0 ]; then
printf ':%s - [0:0]\n' "$chain_name"
fi
printf '%s\n' "-F $chain_name"
cn_fw_jump_index=0
while [ "$cn_fw_jump_index" -lt "$cn_fw_canonical_jumps" ]; do
printf '%s\n' \
"-D DOCKER-USER -m comment --comment $anchor_comment -j $chain_name"
cn_fw_jump_index=$((cn_fw_jump_index + 1))
done
printf '%s\n' \
"-I DOCKER-USER 1 -m comment --comment $anchor_comment -j $chain_name" \
"-A $chain_name -i $OPENPENCIL_CN_INGRESS_INTERFACE -s $OPENPENCIL_CN_GATEWAY_SOURCE_IPV4/32 -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst $OPENPENCIL_CN_SERVICE_IPV4/32 --ctorigdstport 8091 -m comment --comment $allow_relay_comment -j RETURN" \
"-A $chain_name -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst $OPENPENCIL_CN_SERVICE_IPV4/32 --ctorigdstport 8091 -m comment --comment $drop_relay_comment -j DROP" \
"-A $chain_name -i $OPENPENCIL_CN_INGRESS_INTERFACE -s $OPENPENCIL_CN_GATEWAY_SOURCE_IPV4/32 -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst $OPENPENCIL_CN_SERVICE_IPV4/32 --ctorigdstport 8092 -m comment --comment $allow_locator_comment -j RETURN" \
"-A $chain_name -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst $OPENPENCIL_CN_SERVICE_IPV4/32 --ctorigdstport 8092 -m comment --comment $drop_locator_comment -j DROP" \
"-A $chain_name -m comment --comment $fallthrough_comment -j RETURN" \
'COMMIT'
} >"$staged"
# --noflush preserves every unrelated filter rule. Each restore is one kernel
# transaction, so the managed chain and its first-position anchor change as a
# unit or remain at their previous state on failure.
iptables-restore --wait 10 --noflush --test <"$staged"
iptables-restore --wait 10 --noflush <"$staged"
echo "CN Docker ingress firewall reconciled"

View file

@ -0,0 +1,17 @@
#!/bin/sh
set -eu
LC_ALL=C
export LC_ALL
if [ "$#" -ne 1 ]; then
echo "usage: $0 INVENTORY_FILE" >&2
exit 2
fi
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# This helper performs syntax checks only for release validation. The installed
# mutating and kernel-verification paths independently require root ownership.
. "$script_dir/cn-docker-user-firewall-common.sh"
cn_firewall_load_config "$1" syntax-only
echo "CN firewall inventory syntax is valid"

View file

@ -0,0 +1,83 @@
#!/bin/sh
set -eu
LC_ALL=C
export LC_ALL
usage() {
echo "usage: $0 [--syntax-only] INVENTORY LOCATOR_CN_OVERLAY RELAY_CN_OVERLAY" >&2
exit 2
}
ownership_mode=strict
if [ "${1-}" = --syntax-only ]; then
ownership_mode=syntax-only
shift
fi
[ "$#" -eq 3 ] || usage
inventory_file=$1
locator_overlay=$2
relay_overlay=$3
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
. "$script_dir/cn-docker-user-firewall-common.sh"
cn_firewall_load_config "$inventory_file" "$ownership_mode"
extract_overlay_bind() {
cn_fw_overlay=$1
cn_fw_expected_port=$2
if [ ! -f "$cn_fw_overlay" ] || [ -L "$cn_fw_overlay" ] ||
grep -F '${' "$cn_fw_overlay" >/dev/null
then
cn_firewall_fail "CN Compose overlay is missing, symlinked, or mutable"
return 1
fi
cn_fw_host_count=$(grep -Ec '^[[:space:]]+host_ip:[[:space:]]+[0-9.]+$' \
"$cn_fw_overlay" || true)
cn_fw_target_count=$(grep -Ec \
"^[[:space:]]+-[[:space:]]+target:[[:space:]]+$cn_fw_expected_port$" \
"$cn_fw_overlay" || true)
cn_fw_published_count=$(grep -Ec \
"^[[:space:]]+published:[[:space:]]+\"$cn_fw_expected_port\"$" \
"$cn_fw_overlay" || true)
cn_fw_protocol_count=$(grep -Ec \
'^[[:space:]]+protocol:[[:space:]]+tcp$' "$cn_fw_overlay" || true)
cn_fw_ports_count=$(grep -Ec \
'^[[:space:]]+ports:[[:space:]]*$' "$cn_fw_overlay" || true)
cn_fw_list_item_count=$(grep -Ec \
'^[[:space:]]+-[[:space:]]+' "$cn_fw_overlay" || true)
cn_fw_all_hosts=$(grep -Ec \
'^[[:space:]]+host_ip:' "$cn_fw_overlay" || true)
cn_fw_all_targets=$(grep -Ec \
'^[[:space:]]+-[[:space:]]+target:' "$cn_fw_overlay" || true)
cn_fw_all_published=$(grep -Ec \
'^[[:space:]]+published:' "$cn_fw_overlay" || true)
cn_fw_all_protocols=$(grep -Ec \
'^[[:space:]]+protocol:' "$cn_fw_overlay" || true)
if [ "$cn_fw_host_count" -ne 1 ] || [ "$cn_fw_target_count" -ne 1 ] ||
[ "$cn_fw_published_count" -ne 1 ] ||
[ "$cn_fw_protocol_count" -ne 1 ] || [ "$cn_fw_ports_count" -ne 1 ] ||
[ "$cn_fw_list_item_count" -ne 1 ] || [ "$cn_fw_all_hosts" -ne 1 ] ||
[ "$cn_fw_all_targets" -ne 1 ] || [ "$cn_fw_all_published" -ne 1 ] ||
[ "$cn_fw_all_protocols" -ne 1 ]
then
cn_firewall_fail \
"CN Compose overlay must publish exactly its protected IPv4 TCP port"
return 1
fi
cn_fw_extracted_bind=$(awk '$1 == "host_ip:" { print $2 }' \
"$cn_fw_overlay")
cn_firewall_validate_ipv4 "$cn_fw_extracted_bind" || return 1
printf '%s\n' "$cn_fw_extracted_bind"
}
locator_bind=$(extract_overlay_bind "$locator_overlay" 8092)
relay_bind=$(extract_overlay_bind "$relay_overlay" 8091)
if [ "$locator_bind" != "$relay_bind" ] ||
[ "$locator_bind" != "$OPENPENCIL_CN_SERVICE_IPV4" ]
then
cn_firewall_fail \
"inventory service IPv4 must equal both immutable CN Compose host_ip values"
exit 1
fi
printf '%s\n' "$locator_bind"

View file

@ -0,0 +1,11 @@
# Count exact configured IPv4 assignments in `ip -4 -o addr show dev` output.
$3 == "inet" {
split($4, address_and_prefix, "/")
if (address_and_prefix[1] == expected_ipv4 &&
address_and_prefix[2] ~ /^[0-9]+$/) {
matches++
}
}
END {
print matches + 0
}

View file

@ -0,0 +1,158 @@
# Validate the exact five-rule managed-chain shape from one iptables-save
# snapshot. Output: total valid ordered invalid.
function exact_singleton_ipv4(value, expected_ipv4) {
# xtables-nft serializes conntrack's exact `/32` original destination as a
# bare IPv4, while other compatible backends may retain `/32`.
return value == expected_ipv4 || value == expected_ipv4 "/32"
}
$1 == "-A" && $2 == managed_chain {
total++
interface_name = ""
source_ipv4 = ""
protocol = ""
conntrack_modules = 0
comment_modules = 0
ct_direction = ""
original_destination = ""
original_port = ""
rule_comment = ""
target = ""
unknown = 0
interface_options = 0
source_options = 0
protocol_options = 0
direction_options = 0
destination_options = 0
port_options = 0
comment_options = 0
target_options = 0
for (i = 3; i <= NF; i++) {
if (($i == "-i" || $i == "-s" || $i == "-p" ||
$i == "--ctdir" || $i == "--ctorigdst" ||
$i == "--ctorigdstport" || $i == "--comment" ||
$i == "-j") && i < NF) {
option = $i
value = $(i + 1)
i++
if (option == "-i") {
interface_options++
interface_name = value
} else if (option == "-s") {
source_options++
source_ipv4 = value
} else if (option == "-p") {
protocol_options++
protocol = value
} else if (option == "--ctdir") {
direction_options++
ct_direction = value
} else if (option == "--ctorigdst") {
destination_options++
original_destination = value
} else if (option == "--ctorigdstport") {
port_options++
original_port = value
}
else if (option == "--comment") {
comment_options++
rule_comment = value
gsub(/^"|"$/, "", rule_comment)
} else if (option == "-j") {
target_options++
target = value
}
} else if ($i == "-m" && i < NF) {
module_name = $(i + 1)
i++
if (module_name == "conntrack") conntrack_modules++
else if (module_name == "comment") comment_modules++
else unknown++
} else {
unknown++
}
}
expected_comment = ""
expected_interface = ""
expected_source = ""
expected_protocol = "tcp"
expected_conntrack_modules = 1
expected_comment_modules = 1
expected_direction = "ORIGINAL"
expected_destination = service_ipv4
expected_port = ""
expected_target = ""
expected_interface_options = 0
expected_source_options = 0
expected_protocol_options = 1
expected_direction_options = 1
expected_destination_options = 1
expected_port_options = 1
expected_comment_options = 1
expected_target_options = 1
if (total == 1) {
expected_comment = allow_relay_comment
expected_interface = ingress_interface
expected_source = gateway_ipv4 "/32"
expected_port = "8091"
expected_target = "RETURN"
expected_interface_options = 1
expected_source_options = 1
} else if (total == 2) {
expected_comment = drop_relay_comment
expected_port = "8091"
expected_target = "DROP"
} else if (total == 3) {
expected_comment = allow_locator_comment
expected_interface = ingress_interface
expected_source = gateway_ipv4 "/32"
expected_port = "8092"
expected_target = "RETURN"
expected_interface_options = 1
expected_source_options = 1
} else if (total == 4) {
expected_comment = drop_locator_comment
expected_port = "8092"
expected_target = "DROP"
} else if (total == 5) {
expected_comment = fallthrough_comment
expected_protocol = ""
expected_conntrack_modules = 0
expected_direction = ""
expected_destination = ""
expected_target = "RETURN"
expected_protocol_options = 0
expected_direction_options = 0
expected_destination_options = 0
expected_port_options = 0
}
if (unknown == 0 && rule_comment == expected_comment &&
interface_name == expected_interface && source_ipv4 == expected_source &&
protocol == expected_protocol &&
conntrack_modules == expected_conntrack_modules &&
comment_modules == expected_comment_modules &&
ct_direction == expected_direction &&
((expected_destination == "" && original_destination == "") ||
(expected_destination != "" &&
exact_singleton_ipv4(original_destination, expected_destination))) &&
original_port == expected_port && target == expected_target &&
interface_options == expected_interface_options &&
source_options == expected_source_options &&
protocol_options == expected_protocol_options &&
direction_options == expected_direction_options &&
destination_options == expected_destination_options &&
port_options == expected_port_options &&
comment_options == expected_comment_options &&
target_options == expected_target_options) {
valid++
if (total <= 5) ordered++
} else {
invalid++
}
}
END {
print total + 0, valid + 0, ordered + 0, invalid + 0
}

View file

@ -0,0 +1,35 @@
# Inspect an iptables-save filter-table snapshot for every reference to the
# managed chain. Output: total canonical first-position foreign.
$1 == "-A" {
source_chain = $2
if (source_chain == "DOCKER-USER") docker_user_rules++
reference_kind = ""
reference_target = ""
found_comment = ""
for (i = 3; i <= NF; i++) {
if (($i == "-j" || $i == "--jump" || $i == "-g" ||
$i == "--goto") && i < NF) {
reference_kind = $i
reference_target = $(i + 1)
}
if ($i == "--comment" && i < NF) {
found_comment = $(i + 1)
gsub(/^"|"$/, "", found_comment)
}
}
if (reference_target == managed_chain) {
total++
if (source_chain == "DOCKER-USER" && reference_kind == "-j" &&
NF == 8 && $3 == "-m" && $4 == "comment" &&
$5 == "--comment" && found_comment == anchor_comment &&
$7 == "-j" && $8 == managed_chain) {
canonical++
if (docker_user_rules == 1) first++
} else {
foreign++
}
}
}
END {
print total + 0, canonical + 0, first + 0, foreign + 0
}

View file

@ -0,0 +1,277 @@
# Shared, non-executable helpers for the CN service-host firewall scripts.
# Configuration is parsed as data. It is never sourced by a shell.
cn_firewall_fail() {
echo "CN firewall: $*" >&2
return 1
}
cn_firewall_validate_ipv4() {
cn_fw_address=$1
case $cn_fw_address in
''|*[!0-9.]*)
cn_firewall_fail "IPv4 values must use canonical dotted-decimal notation"
return 1
;;
esac
cn_fw_old_ifs=$IFS
IFS=.
set -- $cn_fw_address
IFS=$cn_fw_old_ifs
if [ "$#" -ne 4 ]; then
cn_firewall_fail "IPv4 values must contain exactly four octets"
return 1
fi
for cn_fw_octet in "$@"; do
case $cn_fw_octet in
''|*[!0-9]*)
cn_firewall_fail "IPv4 octets must be decimal integers"
return 1
;;
0) ;;
0*)
cn_firewall_fail "IPv4 octets must not contain leading zeroes"
return 1
;;
esac
if [ "${#cn_fw_octet}" -gt 3 ]; then
cn_firewall_fail "IPv4 octets must contain at most three digits"
return 1
fi
if [ "$cn_fw_octet" -gt 255 ]; then
cn_firewall_fail "IPv4 octets must be in the range 0 through 255"
return 1
fi
done
cn_fw_canonical_address=$1.$2.$3.$4
if [ "$cn_fw_address" != "$cn_fw_canonical_address" ]; then
cn_firewall_fail "IPv4 value is not canonical dotted-decimal"
return 1
fi
case $cn_fw_address in
0.0.0.0|255.255.255.255)
cn_firewall_fail "unspecified and limited-broadcast IPv4 values are forbidden"
return 1
;;
esac
}
cn_firewall_validate_interface() {
cn_fw_interface=$1
case $cn_fw_interface in
[A-Za-z0-9]*) ;;
*)
cn_firewall_fail "ingress interface must start with an ASCII alphanumeric"
return 1
;;
esac
case $cn_fw_interface in
*[!A-Za-z0-9_.:-]*)
cn_firewall_fail "ingress interface contains a forbidden character"
return 1
;;
esac
if [ "${#cn_fw_interface}" -gt 15 ]; then
cn_firewall_fail "ingress interface exceeds Linux IFNAMSIZ"
return 1
fi
}
cn_firewall_mode_is_directory_safe() {
cn_fw_mode=$1
case $cn_fw_mode in
[0-7][0-7][0-7]) ;;
*) return 1 ;;
esac
cn_fw_group_digit=${cn_fw_mode#?}
cn_fw_group_digit=${cn_fw_group_digit%?}
cn_fw_other_digit=${cn_fw_mode#??}
case $cn_fw_group_digit$cn_fw_other_digit in
*[2367]*) return 1 ;;
esac
}
cn_firewall_require_secure_parent_directories() {
cn_fw_secure_path=$1
cn_fw_parent=$(dirname -- "$cn_fw_secure_path")
while [ "$cn_fw_parent" != / ]; do
if [ ! -d "$cn_fw_parent" ] || [ -L "$cn_fw_parent" ] ||
[ "$(stat -c '%u' -- "$cn_fw_parent")" -ne 0 ] ||
[ "$(stat -c '%g' -- "$cn_fw_parent")" -ne 0 ] ||
! cn_firewall_mode_is_directory_safe \
"$(stat -c '%a' -- "$cn_fw_parent")"
then
cn_firewall_fail \
"configuration parents must be root-owned, non-symlink, and non-writable by group/other"
return 1
fi
cn_fw_parent=$(dirname -- "$cn_fw_parent")
done
}
cn_firewall_require_secure_root_file() {
cn_fw_secure_file=$1
cn_fw_required_mode=$2
case $cn_fw_secure_file in
/*) ;;
*)
cn_firewall_fail "secure data path must be absolute"
return 1
;;
esac
if [ ! -f "$cn_fw_secure_file" ] || [ -L "$cn_fw_secure_file" ]; then
cn_firewall_fail "secure data must be a regular non-symlink file"
return 1
fi
if [ "$(stat -c '%u' -- "$cn_fw_secure_file")" -ne 0 ] ||
[ "$(stat -c '%g' -- "$cn_fw_secure_file")" -ne 0 ] ||
[ "$(stat -c '%a' -- "$cn_fw_secure_file")" != \
"$cn_fw_required_mode" ]
then
cn_firewall_fail \
"secure data must have the required root:root ownership and mode"
return 1
fi
cn_firewall_require_secure_parent_directories "$cn_fw_secure_file"
}
cn_firewall_require_secure_config() {
cn_fw_config=$1
if ! cn_firewall_require_secure_root_file "$cn_fw_config" 600; then
return 1
fi
}
cn_firewall_load_config() {
cn_fw_config=$1
cn_fw_ownership_mode=$2
if [ "$cn_fw_ownership_mode" = strict ]; then
cn_firewall_require_secure_config "$cn_fw_config" || return 1
elif [ "$cn_fw_ownership_mode" != syntax-only ]; then
cn_firewall_fail "internal configuration-validation mode is invalid"
return 1
fi
if [ ! -f "$cn_fw_config" ] || [ -L "$cn_fw_config" ]; then
cn_firewall_fail "configuration must be a regular non-symlink file"
return 1
fi
OPENPENCIL_CN_INGRESS_INTERFACE=
OPENPENCIL_CN_GATEWAY_SOURCE_IPV4=
OPENPENCIL_CN_SERVICE_IPV4=
cn_fw_seen_interface=0
cn_fw_seen_source=0
cn_fw_seen_service=0
cn_fw_line_number=0
while IFS= read -r cn_fw_line || [ -n "$cn_fw_line" ]; do
cn_fw_line_number=$((cn_fw_line_number + 1))
case $cn_fw_line in
''|'#'*) continue ;;
*=*) ;;
*)
cn_firewall_fail \
"invalid configuration line $cn_fw_line_number"
return 1
;;
esac
cn_fw_key=${cn_fw_line%%=*}
cn_fw_value=${cn_fw_line#*=}
if [ -z "$cn_fw_value" ]; then
cn_firewall_fail \
"empty configuration value on line $cn_fw_line_number"
return 1
fi
case $cn_fw_key in
OPENPENCIL_CN_INGRESS_INTERFACE)
if [ "$cn_fw_seen_interface" -ne 0 ]; then
cn_firewall_fail "duplicate ingress-interface key"
return 1
fi
cn_fw_seen_interface=1
OPENPENCIL_CN_INGRESS_INTERFACE=$cn_fw_value
;;
OPENPENCIL_CN_GATEWAY_SOURCE_IPV4)
if [ "$cn_fw_seen_source" -ne 0 ]; then
cn_firewall_fail "duplicate gateway-source key"
return 1
fi
cn_fw_seen_source=1
OPENPENCIL_CN_GATEWAY_SOURCE_IPV4=$cn_fw_value
;;
OPENPENCIL_CN_SERVICE_IPV4)
if [ "$cn_fw_seen_service" -ne 0 ]; then
cn_firewall_fail "duplicate service-address key"
return 1
fi
cn_fw_seen_service=1
OPENPENCIL_CN_SERVICE_IPV4=$cn_fw_value
;;
*)
cn_firewall_fail \
"unknown configuration key on line $cn_fw_line_number"
return 1
;;
esac
done <"$cn_fw_config"
if [ "$cn_fw_seen_interface" -ne 1 ] ||
[ "$cn_fw_seen_source" -ne 1 ] ||
[ "$cn_fw_seen_service" -ne 1 ]
then
cn_firewall_fail "configuration must contain each required key exactly once"
return 1
fi
cn_firewall_validate_interface "$OPENPENCIL_CN_INGRESS_INTERFACE" ||
return 1
cn_firewall_validate_ipv4 "$OPENPENCIL_CN_GATEWAY_SOURCE_IPV4" ||
return 1
cn_firewall_validate_ipv4 "$OPENPENCIL_CN_SERVICE_IPV4" || return 1
if [ "$OPENPENCIL_CN_GATEWAY_SOURCE_IPV4" = \
"$OPENPENCIL_CN_SERVICE_IPV4" ]
then
cn_firewall_fail "gateway source and service destination must differ"
return 1
fi
}
cn_firewall_load_deployment_binding() {
cn_fw_binding_file=$1
cn_firewall_require_secure_root_file "$cn_fw_binding_file" 400 || return 1
OPENPENCIL_CN_EXPECTED_SERVICE_IPV4=
cn_fw_binding_seen=0
cn_fw_binding_line_number=0
while IFS= read -r cn_fw_binding_line || [ -n "$cn_fw_binding_line" ]; do
cn_fw_binding_line_number=$((cn_fw_binding_line_number + 1))
case $cn_fw_binding_line in
OPENPENCIL_CN_EXPECTED_SERVICE_IPV4=*)
if [ "$cn_fw_binding_seen" -ne 0 ]; then
cn_firewall_fail "duplicate deployment-binding key"
return 1
fi
cn_fw_binding_seen=1
OPENPENCIL_CN_EXPECTED_SERVICE_IPV4=${cn_fw_binding_line#*=}
;;
*)
cn_firewall_fail \
"invalid deployment-binding line $cn_fw_binding_line_number"
return 1
;;
esac
done <"$cn_fw_binding_file"
if [ "$cn_fw_binding_seen" -ne 1 ]; then
cn_firewall_fail "deployment binding must contain exactly one key"
return 1
fi
cn_firewall_validate_ipv4 "$OPENPENCIL_CN_EXPECTED_SERVICE_IPV4" ||
return 1
if [ "$OPENPENCIL_CN_EXPECTED_SERVICE_IPV4" != \
"$OPENPENCIL_CN_SERVICE_IPV4" ]
then
cn_firewall_fail \
"inventory service IPv4 differs from the installed Compose binding"
return 1
fi
}

View file

@ -0,0 +1,5 @@
# Copy to a private inventory location as root:root mode 0600, then replace
# these RFC 5737 documentation addresses with the reviewed CN inventory.
OPENPENCIL_CN_INGRESS_INTERFACE=eth0
OPENPENCIL_CN_GATEWAY_SOURCE_IPV4=198.51.100.10
OPENPENCIL_CN_SERVICE_IPV4=203.0.113.10

View file

@ -0,0 +1,5 @@
services:
locator:
depends_on:
locator-hsm:
condition: service_healthy

View file

@ -0,0 +1,10 @@
services:
locator:
environment:
OPENPENCIL_COLLAB_LOCATOR_HOME_REGION: cn
OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND: "100"
ports:
- target: 8092
published: "8092"
host_ip: 10.0.0.10
protocol: tcp

View file

@ -0,0 +1,10 @@
services:
locator:
environment:
OPENPENCIL_COLLAB_LOCATOR_HOME_REGION: global
OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND: "100"
ports:
- target: 8092
published: "8092"
host_ip: 127.0.0.1
protocol: tcp

View file

@ -6,7 +6,6 @@ services:
restart: unless-stopped
environment:
OPENPENCIL_COLLAB_LOCATOR_LISTEN: 0.0.0.0:8092
OPENPENCIL_COLLAB_LOCATOR_HOME_REGION: ${OPENPENCIL_COLLAB_LOCATOR_HOME_REGION:?set cn or global}
OPENPENCIL_COLLAB_LOCATOR_TICKET_POLICY_FILE: /run/secrets/collab-policy.json
OPENPENCIL_COLLAB_LOCATOR_HSM_SOCKET: /run/openpencil-hsm/signer.sock
OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID: ${OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID:?set public HSM key id}

View file

@ -0,0 +1,199 @@
#!/bin/sh
set -eu
LC_ALL=C
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL PATH
runtime_dir=/usr/local/libexec/openpencil-collab-cn-firewall
config_dir=/etc/openpencil
config_file=$config_dir/collab-cn-firewall.env
binding_file=$config_dir/collab-cn-firewall-compose.env
unit_file=/etc/systemd/system/openpencil-collab-cn-firewall.service
docker_dropin_dir=/etc/systemd/system/docker.service.d
docker_dropin=$docker_dropin_dir/50-openpencil-collab-cn-firewall.conf
if [ "$#" -ne 1 ]; then
echo "usage: $0 ROOT_OWNED_INVENTORY_FILE" >&2
exit 2
fi
if [ "$(id -u)" -ne 0 ]; then
echo "CN firewall: installation requires root" >&2
exit 1
fi
for command_name in stat install mktemp chmod mv systemctl flock dirname; do
command -v "$command_name" >/dev/null 2>&1 || {
echo "CN firewall: required command is unavailable: $command_name" >&2
exit 1
}
done
inventory_file=$1
case $inventory_file in
/*) ;;
*)
echo "CN firewall: inventory path must be absolute" >&2
exit 1
;;
esac
if [ ! -f "$inventory_file" ] || [ -L "$inventory_file" ] ||
[ "$(stat -c '%u:%g:%a' -- "$inventory_file")" != '0:0:600' ]
then
echo "CN firewall: inventory must be root:root mode 0600 and not a symlink" >&2
exit 1
fi
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
locator_cn_overlay="$script_dir/compose.production.cn.yaml"
relay_cn_overlay="$script_dir/../collab-relay/compose.production.cn.yaml"
for source_file in \
cn-docker-user-firewall-common.sh \
check-cn-docker-user-firewall-references.awk \
check-cn-docker-user-firewall-interface-address.awk \
check-cn-docker-user-firewall-managed-chain.awk \
check-cn-docker-user-firewall-deployment-binding.sh \
apply-cn-docker-user-firewall.sh \
verify-cn-docker-user-firewall.sh \
openpencil-collab-cn-firewall.service \
openpencil-collab-cn-firewall-docker.conf
do
if [ ! -f "$script_dir/$source_file" ] || [ -L "$script_dir/$source_file" ]; then
echo "CN firewall: release artifact is missing or a symlink: $source_file" >&2
exit 1
fi
done
for source_file in "$locator_cn_overlay" "$relay_cn_overlay"; do
if [ ! -f "$source_file" ] || [ -L "$source_file" ]; then
echo "CN firewall: immutable CN Compose overlay is missing or symlinked" >&2
exit 1
fi
done
if systemctl is-active --quiet docker.service; then
docker_initially_active=1
docker_initial_pid=$(systemctl show --property MainPID --value docker.service)
case $docker_initial_pid in
''|0|*[!0-9]*)
echo "CN firewall: active Docker service has no stable main PID" >&2
exit 1
;;
esac
else
docker_initially_active=0
docker_initial_pid=0
fi
install -d -o root -g root -m 0755 "$runtime_dir" "$docker_dropin_dir"
install -o root -g root -m 0644 \
"$script_dir/cn-docker-user-firewall-common.sh" \
"$runtime_dir/cn-docker-user-firewall-common.sh"
install -o root -g root -m 0644 \
"$script_dir/check-cn-docker-user-firewall-references.awk" \
"$runtime_dir/check-cn-docker-user-firewall-references.awk"
install -o root -g root -m 0644 \
"$script_dir/check-cn-docker-user-firewall-interface-address.awk" \
"$runtime_dir/check-cn-docker-user-firewall-interface-address.awk"
install -o root -g root -m 0644 \
"$script_dir/check-cn-docker-user-firewall-managed-chain.awk" \
"$runtime_dir/check-cn-docker-user-firewall-managed-chain.awk"
install -o root -g root -m 0755 \
"$script_dir/check-cn-docker-user-firewall-deployment-binding.sh" \
"$runtime_dir/check-cn-docker-user-firewall-deployment-binding.sh"
install -o root -g root -m 0755 \
"$script_dir/apply-cn-docker-user-firewall.sh" \
"$runtime_dir/apply-cn-docker-user-firewall.sh"
install -o root -g root -m 0755 \
"$script_dir/verify-cn-docker-user-firewall.sh" \
"$runtime_dir/verify-cn-docker-user-firewall.sh"
. "$runtime_dir/cn-docker-user-firewall-common.sh"
# This parent walk happens before the delayed copy. A root-owned 0600 file in
# an attacker-writable directory is rejected, closing pathname replacement.
cn_firewall_require_secure_config "$inventory_file"
if [ ! -e "$config_dir" ]; then
install -d -o root -g root -m 0755 "$config_dir"
elif [ ! -d "$config_dir" ] || [ -L "$config_dir" ] ||
[ "$(stat -c '%u:%g' -- "$config_dir")" != '0:0' ] ||
! cn_firewall_mode_is_directory_safe "$(stat -c '%a' -- "$config_dir")"
then
echo "CN firewall: existing /etc/openpencil directory is not root-owned and safe" >&2
exit 1
fi
expected_service_ipv4=$(
"$runtime_dir/check-cn-docker-user-firewall-deployment-binding.sh" \
"$inventory_file" "$locator_cn_overlay" "$relay_cn_overlay"
)
# Copy into a root-only staging file and validate those immutable bytes. The
# inventory is parsed as data and is never sourced or evaluated as shell code.
umask 077
staged_config=$(mktemp "$config_dir/.collab-cn-firewall.env.XXXXXX")
staged_binding=$(mktemp "$config_dir/.collab-cn-firewall-compose.env.XXXXXX")
cleanup() {
rm -f "$staged_config"
rm -f "$staged_binding"
}
trap cleanup EXIT HUP INT TERM
chmod 0600 "$staged_config"
install -o root -g root -m 0600 "$inventory_file" "$staged_config"
"$runtime_dir/verify-cn-docker-user-firewall.sh" \
--config-only "$staged_config"
staged_expected_service_ipv4=$(
"$runtime_dir/check-cn-docker-user-firewall-deployment-binding.sh" \
"$staged_config" "$locator_cn_overlay" "$relay_cn_overlay"
)
if [ "$staged_expected_service_ipv4" != "$expected_service_ipv4" ]; then
echo "CN firewall: staged inventory changed during deployment binding" >&2
exit 1
fi
printf '%s\n' \
"OPENPENCIL_CN_EXPECTED_SERVICE_IPV4=$expected_service_ipv4" \
>"$staged_binding"
chmod 0400 "$staged_binding"
cn_firewall_load_config "$staged_config" strict
cn_firewall_load_deployment_binding "$staged_binding"
mv -f "$staged_config" "$config_file"
mv -f "$staged_binding" "$binding_file"
trap - EXIT HUP INT TERM
install -o root -g root -m 0644 \
"$script_dir/openpencil-collab-cn-firewall.service" "$unit_file"
install -o root -g root -m 0644 \
"$script_dir/openpencil-collab-cn-firewall-docker.conf" "$docker_dropin"
systemctl daemon-reload
systemctl enable openpencil-collab-cn-firewall.service
if systemctl is-active --quiet docker.service; then
docker_pid_before=$(systemctl show --property MainPID --value docker.service)
case $docker_pid_before in
''|0|*[!0-9]*)
echo "CN firewall: active Docker service has no stable main PID" >&2
exit 1
;;
esac
if [ "$docker_initially_active" -eq 1 ] &&
[ "$docker_pid_before" != "$docker_initial_pid" ]
then
echo "CN firewall: Docker changed state during installation" >&2
exit 1
fi
"$runtime_dir/apply-cn-docker-user-firewall.sh"
"$runtime_dir/verify-cn-docker-user-firewall.sh"
docker_pid_after=$(systemctl show --property MainPID --value docker.service)
if ! systemctl is-active --quiet docker.service ||
[ "$docker_pid_after" != "$docker_pid_before" ]
then
echo "CN firewall: active Docker PID/state changed during direct reconciliation" >&2
exit 1
fi
else
if systemctl is-active --quiet openpencil-collab-cn-firewall.service; then
"$runtime_dir/apply-cn-docker-user-firewall.sh"
"$runtime_dir/verify-cn-docker-user-firewall.sh" --pre-docker
else
systemctl start openpencil-collab-cn-firewall.service
"$runtime_dir/verify-cn-docker-user-firewall.sh" --pre-docker
fi
fi
echo "CN Docker ingress firewall installed; Docker was not restarted"

View file

@ -0,0 +1,7 @@
# Include exactly once in the CN front gateway's Nginx `http` block. The CN
# application service host is the fixed private address 10.0.0.10.
upstream openpencil_collab_locator {
server 10.0.0.10:8092;
keepalive 16;
}

View file

@ -0,0 +1,7 @@
# Include exactly once in the Global application host's Nginx `http` block.
# The locator Compose service on that same host must bind 127.0.0.1:8092.
upstream openpencil_collab_locator {
server 127.0.0.1:8092;
keepalive 16;
}

View file

@ -0,0 +1,31 @@
# Include inside a regional application TLS server after including
# nginx-http-limits.conf and the matching Global-host or CN-front-gateway
# `nginx-http-direct*.conf` once at `http` scope. This application-host
# variant intentionally has no /healthz or catch-all location.
location = /v1/locator {
if ($request_uri != "/v1/locator") {
return 404;
}
if ($http_host = "") {
return 400;
}
access_log off;
limit_req zone=openpencil_locator_per_source burst=20 nodelay;
limit_req_status 429;
limit_conn openpencil_locator_connections 16;
limit_conn_status 429;
client_max_body_size 191;
proxy_http_version 1.1;
proxy_set_header Connection close;
proxy_set_header Host $http_host;
proxy_set_header Authorization $http_authorization;
proxy_set_header Content-Type $http_content_type;
proxy_set_header Accept $http_accept;
proxy_pass http://openpencil_collab_locator/v1/locator;
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
proxy_buffering off;
}

View file

@ -0,0 +1,9 @@
[Unit]
Requires=openpencil-collab-cn-firewall.service
After=openpencil-collab-cn-firewall.service
[Service]
# Reconcile after every daemon start as well as before it. A failed apply or
# verification makes Docker startup fail instead of exposing unguarded ports.
ExecStartPost=/usr/local/libexec/openpencil-collab-cn-firewall/apply-cn-docker-user-firewall.sh
ExecStartPost=/usr/local/libexec/openpencil-collab-cn-firewall/verify-cn-docker-user-firewall.sh

View file

@ -0,0 +1,17 @@
[Unit]
Description=OpenPencil CN Docker ingress firewall pre-start gate
Wants=network-online.target
After=network-online.target
Before=docker.service
PartOf=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/openpencil-collab-cn-firewall/apply-cn-docker-user-firewall.sh
ExecStartPost=/usr/local/libexec/openpencil-collab-cn-firewall/verify-cn-docker-user-firewall.sh --pre-docker
ExecReload=/usr/local/libexec/openpencil-collab-cn-firewall/apply-cn-docker-user-firewall.sh
ExecReload=/usr/local/libexec/openpencil-collab-cn-firewall/verify-cn-docker-user-firewall.sh --pre-docker
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,473 @@
#!/bin/sh
set -eu
LC_ALL=C
export LC_ALL
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
artifact_dir=${1:-$script_dir}
release_artifact_dir=$artifact_dir
if [ "$#" -gt 1 ]; then
echo "usage: $0 [ARTIFACT_DIRECTORY]" >&2
exit 2
fi
common_file=cn-docker-user-firewall-common.sh
reference_checker=check-cn-docker-user-firewall-references.awk
address_checker=check-cn-docker-user-firewall-interface-address.awk
managed_chain_checker=check-cn-docker-user-firewall-managed-chain.awk
deployment_checker=check-cn-docker-user-firewall-deployment-binding.sh
config_checker=check-cn-docker-user-firewall-config.sh
config_example=cn-docker-user-firewall.env.example
apply_file=apply-cn-docker-user-firewall.sh
verify_file=verify-cn-docker-user-firewall.sh
install_file=install-cn-docker-user-firewall.sh
unit_file=openpencil-collab-cn-firewall.service
dropin_file=openpencil-collab-cn-firewall-docker.conf
require_literal() {
cn_fw_pattern=$1
cn_fw_file=$2
grep -F -- "$cn_fw_pattern" "$artifact_dir/$cn_fw_file" >/dev/null || {
echo "CN firewall validation: missing '$cn_fw_pattern' in $cn_fw_file" >&2
return 1
}
}
validate_artifacts() {
cn_fw_dir=$1
artifact_dir=$cn_fw_dir
for cn_fw_file in "$common_file" "$reference_checker" "$address_checker" \
"$managed_chain_checker" "$deployment_checker" "$config_checker" \
"$config_example" \
"$apply_file" "$verify_file" "$install_file" "$unit_file" \
"$dropin_file"
do
[ -f "$artifact_dir/$cn_fw_file" ] || {
echo "CN firewall validation: missing $cn_fw_file" >&2
return 1
}
done
sh -n "$artifact_dir/$common_file" "$artifact_dir/$config_checker" \
"$artifact_dir/$deployment_checker" \
"$artifact_dir/$apply_file" "$artifact_dir/$verify_file" \
"$artifact_dir/$install_file" "$artifact_dir/$unit_file" \
"$artifact_dir/$dropin_file"
for cn_fw_pattern in \
'cn_firewall_load_config "$config_file" strict' \
'cn_firewall_load_deployment_binding "$binding_file"' \
'configured service IPv4 must be assigned exactly once to the ingress interface' \
'foreign jump or goto reference into the managed chain is forbidden' \
'existing unanchored managed-chain name is not an exact prior OpenPencil chain' \
'iptables-restore --wait 10 --noflush --test' \
'iptables-restore --wait 10 --noflush' \
'-I DOCKER-USER 1' \
'-F $chain_name' \
'--ctdir ORIGINAL' \
'--ctorigdst $OPENPENCIL_CN_SERVICE_IPV4/32' \
'--ctorigdstport 8091' \
'--ctorigdstport 8092' \
'-j DROP' \
"$fallthrough_literal"
do
require_literal "$cn_fw_pattern" "$apply_file"
done
if [ "$(grep -Fc -- '--ctorigdstport 8091' \
"$artifact_dir/$apply_file")" -ne 2 ] ||
[ "$(grep -Fc -- '--ctorigdstport 8092' \
"$artifact_dir/$apply_file")" -ne 2 ] ||
[ "$(grep -Fc -- '-j DROP' "$artifact_dir/$apply_file")" -ne 2 ]
then
echo "CN firewall validation: protected-port allow/drop rules are not exact" >&2
return 1
fi
for cn_fw_pattern in \
'cn_firewall_load_deployment_binding "$binding_file"' \
'configured service IPv4 must be assigned exactly once to the ingress interface' \
'managed chain requires one first DOCKER-USER jump and no foreign jump/goto references' \
'Docker must place one first-position DOCKER-USER jump in FORWARD' \
'managed chain predicates, order, or rule count are not exact' \
'filter_snapshot=$(iptables-save -t filter)'
do
require_literal "$cn_fw_pattern" "$verify_file"
done
if grep -F 'iptables-restore' "$artifact_dir/$verify_file" >/dev/null; then
echo "CN firewall validation: read-only verifier must not restore rules" >&2
return 1
fi
for cn_fw_pattern in \
'$i == "-j"' \
'$i == "-g"' \
'$i == "--goto"' \
'source_chain == "DOCKER-USER"' \
'foreign++'
do
require_literal "$cn_fw_pattern" "$reference_checker"
done
for cn_fw_pattern in \
'$3 == "inet"' \
'address_and_prefix[1] == expected_ipv4'
do
require_literal "$cn_fw_pattern" "$address_checker"
done
for cn_fw_pattern in \
'$1 == "-A" && $2 == managed_chain' \
'exact_singleton_ipv4(original_destination, expected_destination)' \
'source_ipv4 == expected_source' \
'print total + 0, valid + 0, ordered + 0, invalid + 0'
do
require_literal "$cn_fw_pattern" "$managed_chain_checker"
done
for cn_fw_pattern in \
'cn_firewall_load_config "$inventory_file" "$ownership_mode"' \
'CN Compose overlay must publish exactly its protected IPv4 TCP port' \
'inventory service IPv4 must equal both immutable CN Compose host_ip values' \
'extract_overlay_bind "$locator_overlay" 8092' \
'extract_overlay_bind "$relay_overlay" 8091'
do
require_literal "$cn_fw_pattern" "$deployment_checker"
done
for cn_fw_pattern in \
'secure data must have the required root:root ownership and mode' \
'configuration must contain each required key exactly once' \
'IPv4 values must use canonical dotted-decimal notation' \
'IPv4 octets must contain at most three digits' \
'IPv4 value is not canonical dotted-decimal' \
'inventory service IPv4 differs from the installed Compose binding' \
'ingress interface exceeds Linux IFNAMSIZ'
do
require_literal "$cn_fw_pattern" "$common_file"
done
if grep -Eq '^[[:space:]]*(eval|source|\.)[[:space:]]' \
"$artifact_dir/$common_file"
then
echo "CN firewall validation: inventory must never be shell-evaluated" >&2
return 1
fi
for cn_fw_pattern in \
'OPENPENCIL_CN_INGRESS_INTERFACE=eth0' \
'OPENPENCIL_CN_GATEWAY_SOURCE_IPV4=198.51.100.10' \
'OPENPENCIL_CN_SERVICE_IPV4=203.0.113.10'
do
require_literal "$cn_fw_pattern" "$config_example"
done
for cn_fw_pattern in \
'inventory must be root:root mode 0600 and not a symlink' \
'cn_firewall_require_secure_config "$inventory_file"' \
'check-cn-docker-user-firewall-references.awk' \
'check-cn-docker-user-firewall-deployment-binding.sh' \
'staged inventory changed during deployment binding' \
'existing /etc/openpencil directory is not root-owned and safe' \
'systemctl enable openpencil-collab-cn-firewall.service' \
'docker_pid_before=$(systemctl show --property MainPID --value docker.service)' \
'docker_pid_after=$(systemctl show --property MainPID --value docker.service)' \
'active Docker PID/state changed during direct reconciliation' \
'Docker was not restarted'
do
require_literal "$cn_fw_pattern" "$install_file"
done
if grep -Eq 'systemctl[[:space:]]+(restart|stop|try-restart)' \
"$artifact_dir/$install_file"
then
echo "CN firewall validation: installer must not restart or stop a unit" >&2
return 1
fi
for cn_fw_pattern in \
'Before=docker.service' \
'PartOf=docker.service' \
'ExecStartPost=/usr/local/libexec/openpencil-collab-cn-firewall/verify-cn-docker-user-firewall.sh --pre-docker' \
'WantedBy=multi-user.target'
do
require_literal "$cn_fw_pattern" "$unit_file"
done
for cn_fw_pattern in \
'Requires=openpencil-collab-cn-firewall.service' \
'After=openpencil-collab-cn-firewall.service' \
'ExecStartPost=/usr/local/libexec/openpencil-collab-cn-firewall/apply-cn-docker-user-firewall.sh' \
'ExecStartPost=/usr/local/libexec/openpencil-collab-cn-firewall/verify-cn-docker-user-firewall.sh'
do
require_literal "$cn_fw_pattern" "$dropin_file"
done
if grep -Eq '(^|[^0-9])(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)' \
"$artifact_dir/$common_file" "$artifact_dir/$reference_checker" \
"$artifact_dir/$address_checker" "$artifact_dir/$managed_chain_checker" \
"$artifact_dir/$deployment_checker" \
"$artifact_dir/$config_checker" \
"$artifact_dir/$config_example" "$artifact_dir/$apply_file" \
"$artifact_dir/$verify_file" "$artifact_dir/$install_file" \
"$artifact_dir/$unit_file" "$artifact_dir/$dropin_file"
then
echo "CN firewall validation: private inventory addresses must not be checked in" >&2
return 1
fi
}
assert_reference_guard() {
cn_fw_reference_dir=$1
cn_fw_canonical='-A DOCKER-USER -m comment --comment openpencil-cn-ingress-v1 -j OPENPENCIL-CN-INGRESS'
cn_fw_summary=$(printf '%s\n' '*filter' ':DOCKER-USER - [0:0]' \
':OPENPENCIL-CN-INGRESS - [0:0]' "$cn_fw_canonical" 'COMMIT' |
awk -v managed_chain=OPENPENCIL-CN-INGRESS \
-v anchor_comment=openpencil-cn-ingress-v1 \
-f "$cn_fw_reference_dir/$reference_checker")
[ "$cn_fw_summary" = '1 1 1 0' ] || return 1
for cn_fw_foreign_rule in \
'-A FORWARD -j OPENPENCIL-CN-INGRESS' \
'-A INPUT -j OPENPENCIL-CN-INGRESS' \
'-A FOREIGN-CUSTOM -j OPENPENCIL-CN-INGRESS' \
'-A DOCKER-USER -g OPENPENCIL-CN-INGRESS' \
'-A FOREIGN-CUSTOM --goto OPENPENCIL-CN-INGRESS'
do
cn_fw_summary=$(printf '%s\n' '*filter' ':DOCKER-USER - [0:0]' \
':OPENPENCIL-CN-INGRESS - [0:0]' \
':FOREIGN-CUSTOM - [0:0]' "$cn_fw_canonical" \
"$cn_fw_foreign_rule" 'COMMIT' |
awk -v managed_chain=OPENPENCIL-CN-INGRESS \
-v anchor_comment=openpencil-cn-ingress-v1 \
-f "$cn_fw_reference_dir/$reference_checker")
[ "$cn_fw_summary" = '2 1 1 1' ] || return 1
done
}
assert_address_guard() {
cn_fw_address_dir=$1
cn_fw_fake_ip_output=$(printf '%s\n' \
'2: eth0 inet 203.0.113.10/24 brd 203.0.113.255 scope global eth0' \
'2: eth0 inet 203.0.113.20/24 brd 203.0.113.255 scope global secondary eth0')
cn_fw_summary=$(printf '%s\n' "$cn_fw_fake_ip_output" | awk \
-v expected_ipv4=203.0.113.10 \
-f "$cn_fw_address_dir/$address_checker")
[ "$cn_fw_summary" = 1 ] || return 1
cn_fw_summary=$(printf '%s\n' "$cn_fw_fake_ip_output" | awk \
-v expected_ipv4=203.0.113.11 \
-f "$cn_fw_address_dir/$address_checker")
[ "$cn_fw_summary" = 0 ] || return 1
cn_fw_duplicate_output=$(printf '%s\n%s\n' \
'2: eth0 inet 203.0.113.10/24 scope global eth0' \
'2: eth0 inet 203.0.113.10/32 scope global secondary eth0')
cn_fw_summary=$(printf '%s\n' "$cn_fw_duplicate_output" | awk \
-v expected_ipv4=203.0.113.10 \
-f "$cn_fw_address_dir/$address_checker")
[ "$cn_fw_summary" = 2 ] || return 1
}
managed_chain_summary() {
cn_fw_managed_dir=$1
cn_fw_managed_snapshot=$2
printf '%s\n' "$cn_fw_managed_snapshot" | awk \
-v managed_chain=OPENPENCIL-CN-INGRESS \
-v ingress_interface=eth0 -v gateway_ipv4=198.51.100.10 \
-v service_ipv4=203.0.113.10 \
-v allow_relay_comment=openpencil-cn-allow-8091-v1 \
-v drop_relay_comment=openpencil-cn-drop-8091-v1 \
-v allow_locator_comment=openpencil-cn-allow-8092-v1 \
-v drop_locator_comment=openpencil-cn-drop-8092-v1 \
-v fallthrough_comment=openpencil-cn-fallthrough-v1 \
-f "$cn_fw_managed_dir/$managed_chain_checker"
}
assert_managed_chain_guard() {
cn_fw_managed_dir=$1
cn_fw_valid_managed=$(printf '%s\n' \
'-A OPENPENCIL-CN-INGRESS -i eth0 -s 198.51.100.10/32 -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst 203.0.113.10/32 --ctorigdstport 8091 -m comment --comment openpencil-cn-allow-8091-v1 -j RETURN' \
'-A OPENPENCIL-CN-INGRESS -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst 203.0.113.10/32 --ctorigdstport 8091 -m comment --comment openpencil-cn-drop-8091-v1 -j DROP' \
'-A OPENPENCIL-CN-INGRESS -i eth0 -s 198.51.100.10/32 -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst 203.0.113.10/32 --ctorigdstport 8092 -m comment --comment openpencil-cn-allow-8092-v1 -j RETURN' \
'-A OPENPENCIL-CN-INGRESS -p tcp -m conntrack --ctdir ORIGINAL --ctorigdst 203.0.113.10/32 --ctorigdstport 8092 -m comment --comment openpencil-cn-drop-8092-v1 -j DROP' \
'-A OPENPENCIL-CN-INGRESS -m comment --comment openpencil-cn-fallthrough-v1 -j RETURN')
[ "$(managed_chain_summary "$cn_fw_managed_dir" \
"$cn_fw_valid_managed")" = '5 5 5 0' ] || return 1
cn_fw_live_serialized=$(printf '%s\n' \
'-A OPENPENCIL-CN-INGRESS -s 198.51.100.10/32 -i eth0 -p tcp -m conntrack --ctorigdst 203.0.113.10 --ctorigdstport 8091 --ctdir ORIGINAL -m comment --comment openpencil-cn-allow-8091-v1 -j RETURN' \
'-A OPENPENCIL-CN-INGRESS -p tcp -m conntrack --ctorigdst 203.0.113.10 --ctorigdstport 8091 --ctdir ORIGINAL -m comment --comment openpencil-cn-drop-8091-v1 -j DROP' \
'-A OPENPENCIL-CN-INGRESS -s 198.51.100.10/32 -i eth0 -p tcp -m conntrack --ctorigdst 203.0.113.10 --ctorigdstport 8092 --ctdir ORIGINAL -m comment --comment openpencil-cn-allow-8092-v1 -j RETURN' \
'-A OPENPENCIL-CN-INGRESS -p tcp -m conntrack --ctorigdst 203.0.113.10 --ctorigdstport 8092 --ctdir ORIGINAL -m comment --comment openpencil-cn-drop-8092-v1 -j DROP' \
'-A OPENPENCIL-CN-INGRESS -m comment --comment openpencil-cn-fallthrough-v1 -j RETURN')
[ "$(managed_chain_summary "$cn_fw_managed_dir" \
"$cn_fw_live_serialized")" = '5 5 5 0' ] || return 1
cn_fw_unowned='-A OPENPENCIL-CN-INGRESS -j RETURN'
[ "$(managed_chain_summary "$cn_fw_managed_dir" "$cn_fw_unowned")" != \
'5 5 5 0' ] || return 1
cn_fw_weakened=$(printf '%s\n' "$cn_fw_valid_managed" |
sed '1s/198\.51\.100\.10\/32/198.51.100.11\/32/')
[ "$(managed_chain_summary "$cn_fw_managed_dir" "$cn_fw_weakened")" != \
'5 5 5 0' ] || return 1
cn_fw_wide_destination=$(printf '%s\n' "$cn_fw_live_serialized" |
sed '1s/203\.0\.113\.10/203.0.113.10\/31/')
[ "$(managed_chain_summary "$cn_fw_managed_dir" \
"$cn_fw_wide_destination")" != '5 5 5 0' ] || return 1
cn_fw_wrong_destination=$(printf '%s\n' "$cn_fw_live_serialized" |
sed '1s/203\.0\.113\.10/203.0.113.11/')
[ "$(managed_chain_summary "$cn_fw_managed_dir" \
"$cn_fw_wrong_destination")" != '5 5 5 0' ] || return 1
}
fallthrough_literal='-A $chain_name -m comment --comment $fallthrough_comment -j RETURN'
validate_artifacts "$artifact_dir"
assert_reference_guard "$artifact_dir" || {
echo "CN firewall validation: foreign-reference guard rejected its test model" >&2
exit 1
}
assert_address_guard "$artifact_dir" || {
echo "CN firewall validation: fake ip-address guard rejected its test model" >&2
exit 1
}
assert_managed_chain_guard "$artifact_dir" || {
echo "CN firewall validation: managed-chain ownership guard rejected its test model" >&2
exit 1
}
sh "$artifact_dir/$config_checker" "$artifact_dir/$config_example" >/dev/null
mutation_dir=$(mktemp -d "${TMPDIR:-/tmp}/openpencil-cn-firewall.XXXXXX")
cleanup() {
rm -rf "$mutation_dir"
}
trap cleanup EXIT HUP INT TERM
for cn_fw_file in "$common_file" "$reference_checker" "$address_checker" \
"$managed_chain_checker" "$deployment_checker" "$config_checker" \
"$config_example" \
"$apply_file" "$verify_file" "$install_file" "$unit_file" "$dropin_file"
do
cp "$artifact_dir/$cn_fw_file" "$mutation_dir/$cn_fw_file"
done
expect_config_failure() {
cn_fw_label=$1
if sh "$mutation_dir/$config_checker" "$mutation_dir/mutated.env" \
>/dev/null 2>&1
then
echo "CN firewall validation: accepted mutation: $cn_fw_label" >&2
exit 1
fi
}
sed 's/OPENPENCIL_CN_INGRESS_INTERFACE=eth0/OPENPENCIL_CN_INGRESS_INTERFACE=eth0;id/' \
"$artifact_dir/$config_example" >"$mutation_dir/mutated.env"
expect_config_failure 'shell metacharacter in interface'
sed 's/198\.51\.100\.10/gateway.internal/' "$artifact_dir/$config_example" \
>"$mutation_dir/mutated.env"
expect_config_failure 'hostname in numeric IPv4 field'
sed 's/203\.0\.113\.10/203.0.113.010/' "$artifact_dir/$config_example" \
>"$mutation_dir/mutated.env"
expect_config_failure 'non-canonical IPv4 octet'
sed 's/198\.51\.100\.10/198.51.100.10./' "$artifact_dir/$config_example" \
>"$mutation_dir/mutated.env"
expect_config_failure 'trailing empty IPv4 octet'
sed 's/198\.51\.100\.10/999999999999999999999999999999/' \
"$artifact_dir/$config_example" >"$mutation_dir/mutated.env"
expect_config_failure 'overflowing IPv4 octet'
{
sed -n '1,$p' "$artifact_dir/$config_example"
printf '%s\n' 'OPENPENCIL_CN_SERVICE_IPV4=203.0.113.11'
} >"$mutation_dir/mutated.env"
expect_config_failure 'duplicate required key'
printf '%s\n' \
'services:' \
' locator:' \
' ports:' \
' - target: 8092' \
' published: "8092"' \
' host_ip: 203.0.113.10' \
' protocol: tcp' >"$mutation_dir/locator.cn.yaml"
printf '%s\n' \
'services:' \
' relay:' \
' ports:' \
' - target: 8091' \
' published: "8091"' \
' host_ip: 203.0.113.10' \
' protocol: tcp' >"$mutation_dir/relay.cn.yaml"
sh "$artifact_dir/$deployment_checker" --syntax-only \
"$artifact_dir/$config_example" "$mutation_dir/locator.cn.yaml" \
"$mutation_dir/relay.cn.yaml" >/dev/null
sed 's/203\.0\.113\.10/203.0.113.11/' "$mutation_dir/relay.cn.yaml" \
>"$mutation_dir/relay.cn.mismatch.yaml"
if sh "$artifact_dir/$deployment_checker" --syntax-only \
"$artifact_dir/$config_example" "$mutation_dir/locator.cn.yaml" \
"$mutation_dir/relay.cn.mismatch.yaml" >/dev/null 2>&1
then
echo "CN firewall validation: mismatched CN overlay bind was accepted" >&2
exit 1
fi
actual_locator_overlay="$script_dir/compose.production.cn.yaml"
actual_relay_overlay="$script_dir/../collab-relay/compose.production.cn.yaml"
actual_service_ipv4=$(awk '$1 == "host_ip:" { print $2 }' \
"$actual_locator_overlay")
sed "s/OPENPENCIL_CN_SERVICE_IPV4=203\.0\.113\.10/OPENPENCIL_CN_SERVICE_IPV4=$actual_service_ipv4/" \
"$artifact_dir/$config_example" >"$mutation_dir/actual.env"
sh "$artifact_dir/$deployment_checker" --syntax-only \
"$mutation_dir/actual.env" "$actual_locator_overlay" \
"$actual_relay_overlay" >/dev/null
sed 's/$i == "-g"/$i == "-x"/' \
"$artifact_dir/$reference_checker" >"$mutation_dir/$reference_checker.tmp"
mv "$mutation_dir/$reference_checker.tmp" "$mutation_dir/$reference_checker"
if assert_reference_guard "$mutation_dir" >/dev/null 2>&1; then
echo "CN firewall validation: lost goto-reference check was accepted" >&2
exit 1
fi
cp "$artifact_dir/$reference_checker" "$mutation_dir/$reference_checker"
sed 's/address_and_prefix\[1\] == expected_ipv4/address_and_prefix[1] != expected_ipv4/' \
"$artifact_dir/$address_checker" >"$mutation_dir/$address_checker.tmp"
mv "$mutation_dir/$address_checker.tmp" "$mutation_dir/$address_checker"
if assert_address_guard "$mutation_dir" >/dev/null 2>&1; then
echo "CN firewall validation: lost exact live-address check was accepted" >&2
exit 1
fi
cp "$artifact_dir/$address_checker" "$mutation_dir/$address_checker"
sed 's/source_ipv4 == expected_source/source_ipv4 != expected_source/' \
"$artifact_dir/$managed_chain_checker" \
>"$mutation_dir/$managed_chain_checker.tmp"
mv "$mutation_dir/$managed_chain_checker.tmp" \
"$mutation_dir/$managed_chain_checker"
if assert_managed_chain_guard "$mutation_dir" >/dev/null 2>&1; then
echo "CN firewall validation: weakened dormant-chain ownership check was accepted" >&2
exit 1
fi
cp "$artifact_dir/$managed_chain_checker" \
"$mutation_dir/$managed_chain_checker"
sed 's/value == expected_ipv4 || value == expected_ipv4 "\/32"/value == expected_ipv4 "\/32"/' \
"$artifact_dir/$managed_chain_checker" \
>"$mutation_dir/$managed_chain_checker.tmp"
mv "$mutation_dir/$managed_chain_checker.tmp" \
"$mutation_dir/$managed_chain_checker"
if assert_managed_chain_guard "$mutation_dir" >/dev/null 2>&1; then
echo "CN firewall validation: lost xtables singleton normalization was accepted" >&2
exit 1
fi
cp "$artifact_dir/$managed_chain_checker" \
"$mutation_dir/$managed_chain_checker"
{
sed -n '1,$p' "$artifact_dir/$install_file"
printf '%s\n' 'systemctl restart docker.service'
} >"$mutation_dir/$install_file"
if validate_artifacts "$mutation_dir" >/dev/null 2>&1; then
echo "CN firewall validation: active-Docker restart mutation was accepted" >&2
exit 1
fi
artifact_dir=$release_artifact_dir
cp "$release_artifact_dir/$install_file" "$mutation_dir/$install_file"
sed 's/--ctorigdstport 8092/--dport 8092/g' \
"$release_artifact_dir/$apply_file" >"$mutation_dir/$apply_file.tmp"
mv "$mutation_dir/$apply_file.tmp" "$mutation_dir/$apply_file"
if validate_artifacts "$mutation_dir" >/dev/null 2>&1; then
echo "CN firewall validation: lost original-destination match was accepted" >&2
exit 1
fi
echo "CN Docker ingress firewall artifacts validated"

View file

@ -3,9 +3,27 @@ set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
compose_file="$script_dir/compose.yaml"
locator_global="$script_dir/compose.production.global.yaml"
locator_cn="$script_dir/compose.production.cn.yaml"
dockerfile="$script_dir/Dockerfile"
nginx_config="$script_dir/nginx-location.conf"
nginx_limits="$script_dir/nginx-http-limits.conf"
nginx_direct_location="$script_dir/nginx-location-direct.conf"
nginx_direct_global="$script_dir/nginx-http-direct.conf"
nginx_direct_cn="$script_dir/nginx-http-direct-cn-gateway.conf"
relay_dir="$script_dir/../collab-relay"
relay_compose="$relay_dir/compose.yaml"
relay_production="$relay_dir/compose.production.yaml"
relay_global="$relay_dir/compose.production.global.yaml"
relay_cn="$relay_dir/compose.production.cn.yaml"
relay_direct_global="$relay_dir/nginx-http-direct.conf"
relay_direct_cn="$relay_dir/nginx-http-direct-cn-gateway.conf"
hsm_dir="$script_dir/../collab-relay-locator-hsm"
hsm_compose="$hsm_dir/compose.yaml"
hsm_overlay="$script_dir/compose.hsm.yaml"
hsm_dockerfile="$hsm_dir/Dockerfile"
hsm_tmpfiles="$hsm_dir/openpencil-locator-hsm.conf"
cn_firewall_validator="$script_dir/validate-cn-docker-user-firewall.sh"
require_literal() {
pattern=$1
@ -13,6 +31,8 @@ require_literal() {
grep -F "$pattern" "$file" >/dev/null
}
sh "$cn_firewall_validator" "$script_dir"
for pattern in \
'ENTRYPOINT ["/usr/local/bin/op-collab-relay-locator-server", "--production"]' \
'FROM rust:1.94-bookworm@sha256:6ae102bdbf528294bc79ad6e1fae682f6f7c2a6e6621506ba959f9685b308a55 AS build' \
@ -22,6 +42,29 @@ do
require_literal "$pattern" "$dockerfile"
done
for pattern in \
'cargo build --locked --release -p op-collab-relay-locator-hsm' \
'USER ${SIGNER_UID}:${SHARED_GID}' \
'HEALTHCHECK --interval=30s' \
'CMD ["serve", "--config", "/run/openpencil-config/locator-hsm.json"]'
do
require_literal "$pattern" "$hsm_dockerfile"
done
for pattern in \
'network_mode: none' \
'read_only: true' \
'no-new-privileges:true' \
'target: /run/secrets/locator-hsm-pin' \
'target: /var/lib/openpencil-softhsm/tokens' \
'target: /run/openpencil-hsm'
do
require_literal "$pattern" "$hsm_compose"
done
require_literal 'condition: service_healthy' "$hsm_overlay"
require_literal 'd /run/openpencil/locator-hsm 0770 root 65532 -' "$hsm_tmpfiles"
for pattern in \
'read_only: true' \
'cap_drop:' \
@ -32,25 +75,37 @@ do
require_literal "$pattern" "$compose_file"
done
require_literal 'OPENPENCIL_COLLAB_LOCATOR_HOME_REGION: global' "$locator_global"
require_literal 'OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND: "100"' \
"$locator_global"
require_literal 'host_ip: 127.0.0.1' "$locator_global"
require_literal 'target: 8092' "$locator_global"
require_literal 'published: "8092"' "$locator_global"
require_literal 'OPENPENCIL_COLLAB_LOCATOR_HOME_REGION: cn' "$locator_cn"
require_literal 'OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND: "100"' \
"$locator_cn"
require_literal 'host_ip: 10.0.0.10' "$locator_cn"
require_literal 'target: 8092' "$locator_cn"
require_literal 'published: "8092"' "$locator_cn"
require_literal 'OPENPENCIL_COLLAB_RELAY_HOME_REGION: global' "$relay_global"
require_literal 'OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE: "1024"' \
"$relay_global"
require_literal 'host_ip: 127.0.0.1' "$relay_global"
require_literal 'target: 8091' "$relay_global"
require_literal 'published: "8091"' "$relay_global"
require_literal 'OPENPENCIL_COLLAB_RELAY_HOME_REGION: cn' "$relay_cn"
require_literal 'OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE: "1024"' \
"$relay_cn"
require_literal 'host_ip: 10.0.0.10' "$relay_cn"
require_literal 'target: 8091' "$relay_cn"
require_literal 'published: "8091"' "$relay_cn"
for pattern in \
'location = /v1/locator {' \
'if ($request_uri != "/v1/locator") {' \
'if ($http_host = "") {' \
'client_max_body_size 191;' \
'location = /v1/pairing-code {' \
'if ($request_uri != "/v1/pairing-code") {' \
'client_max_body_size 624;' \
'client_body_buffer_size 624;' \
'application/vnd.openpencil.relay-pairing-publish-v1' \
'proxy_pass http://locator:8092/v1/pairing-code;' \
'location = /v1/pairing-code/claim {' \
'if ($request_uri != "/v1/pairing-code/claim") {' \
'client_max_body_size 49;' \
'client_body_buffer_size 49;' \
'if ($content_length != "49") {' \
'application/vnd.openpencil.relay-pairing-claim-v1' \
'application/vnd.openpencil.relay-sealed-invite-v1' \
'proxy_pass http://locator:8092/v1/pairing-code/claim;' \
'proxy_set_header Authorization $http_authorization;' \
'proxy_set_header Host $http_host;' \
'proxy_buffering off;' \
@ -62,29 +117,12 @@ do
require_literal "$pattern" "$nginx_config"
done
for counted_pattern in \
'limit_except POST {' \
'if ($http_transfer_encoding != "") {' \
'if ($http_content_encoding != "") {' \
'proxy_pass_request_headers off;' \
'proxy_set_header Transfer-Encoding "";' \
'proxy_set_header Content-Encoding "";' \
'proxy_request_buffering on;'
do
if [ "$(grep -Fc -- "$counted_pattern" "$nginx_config" || true)" -ne 2 ]; then
echo "pairing ingress must enforce $counted_pattern on both routes" >&2
exit 1
fi
done
if [ "$(grep -Ec '^location = /v1/' "$nginx_config" || true)" -ne 3 ]; then
echo "locator ingress must expose exactly three /v1 routes" >&2
exit 1
fi
if [ "$(grep -Ec '^[[:space:]]*proxy_pass[[:space:]]+http://locator:8092/' "$nginx_config" || true)" -ne 4 ]; then
echo "locator ingress must use exactly four fixed locator upstream routes" >&2
exit 1
fi
require_literal 'proxy_pass http://openpencil_collab_locator/v1/locator;' \
"$nginx_direct_location"
require_literal 'server 127.0.0.1:8092;' "$nginx_direct_global"
require_literal 'server 10.0.0.10:8092;' "$nginx_direct_cn"
require_literal 'server 127.0.0.1:8091;' "$relay_direct_global"
require_literal 'server 10.0.0.10:8091;' "$relay_direct_cn"
for pattern in \
'limit_req_zone $binary_remote_addr zone=openpencil_locator_per_source:10m rate=10r/s;' \
@ -105,10 +143,40 @@ do
done
if grep -Eq \
'^[[:space:]]*(ports:|privileged:|network_mode:[[:space:]]*host)' \
'^[[:space:]]*(privileged:|network_mode:[[:space:]]*host)' \
"$compose_file"
then
echo "locator container must not publish a host port or gain host privileges" >&2
echo "locator container must not gain host privileges" >&2
exit 1
fi
if grep -Eq '^[[:space:]]*ports:' "$compose_file" "$relay_compose" \
"$relay_production" ||
grep -Eq 'OPENPENCIL_COLLAB_(LOCATOR|RELAY)_(HOST_BIND|HOME_REGION)' \
"$compose_file" "$relay_compose" "$relay_production" ||
grep -Eq \
'OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND|OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE' \
"$compose_file" "$relay_compose" "$relay_production" ||
grep -F '${' "$locator_global" "$locator_cn" "$relay_global" \
"$relay_cn" >/dev/null
then
echo "common Compose must not publish ports; regional overlays must be immutable" >&2
exit 1
fi
if grep -Eq 'host_ip:[[:space:]]*(0\.0\.0\.0|::)' \
"$locator_global" "$locator_cn" "$relay_global" "$relay_cn"
then
echo "regional production overlays must not bind wildcard addresses" >&2
exit 1
fi
if grep -Eq \
'server[[:space:]]+(relay|locator):809[12]|proxy_pass[[:space:]]+http://(relay|locator):809[12]' \
"$nginx_direct_location" "$nginx_direct_global" "$nginx_direct_cn" \
"$relay_direct_global" "$relay_direct_cn"
then
echo "direct-host Nginx must not use Compose-only service DNS" >&2
exit 1
fi
@ -123,13 +191,91 @@ fi
if command -v docker >/dev/null 2>&1 &&
docker compose version >/dev/null 2>&1
then
OPENPENCIL_COLLAB_LOCATOR_HOME_REGION=cn \
OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID=locator-prod-2026-07 \
OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID=65532 \
OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID=65532 \
OPENPENCIL_COLLAB_POLICY_HOST_FILE=/dev/null \
OPENPENCIL_COLLAB_HSM_CONFIG_HOST_FILE=/dev/null \
OPENPENCIL_COLLAB_HSM_PIN_HOST_FILE=/dev/null \
OPENPENCIL_COLLAB_HSM_TOKEN_HOST_DIR=/tmp \
OPENPENCIL_COLLAB_HSM_SOCKET_HOST_DIR=/tmp \
docker compose -f "$compose_file" config -q
docker compose -f "$hsm_compose" config -q
for region in global cn
do
case $region in
global)
bind=127.0.0.1
hostile_region=cn
locator_overlay=$locator_global
relay_overlay=$relay_global
;;
cn)
bind=10.0.0.10
hostile_region=global
locator_overlay=$locator_cn
relay_overlay=$relay_cn
;;
esac
locator_config=$(
OPENPENCIL_COLLAB_LOCATOR_HOME_REGION=$hostile_region \
OPENPENCIL_COLLAB_LOCATOR_HOST_BIND=0.0.0.0 \
OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND=1 \
OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID=locator-prod-2026-07 \
OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID=65533 \
OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID=65532 \
OPENPENCIL_COLLAB_POLICY_HOST_FILE=/dev/null \
OPENPENCIL_COLLAB_HSM_SOCKET_HOST_DIR=/tmp \
OPENPENCIL_COLLAB_HSM_CONFIG_HOST_FILE=/dev/null \
OPENPENCIL_COLLAB_HSM_PIN_HOST_FILE=/dev/null \
OPENPENCIL_COLLAB_HSM_TOKEN_HOST_DIR=/tmp \
docker compose \
-f "$hsm_compose" -f "$compose_file" \
-f "$locator_overlay" -f "$hsm_overlay" config
)
printf '%s\n' "$locator_config" | grep -F "host_ip: $bind" >/dev/null
printf '%s\n' "$locator_config" | grep -F 'target: 8092' >/dev/null
printf '%s\n' "$locator_config" | grep -F 'published: "8092"' >/dev/null
printf '%s\n' "$locator_config" |
grep -F "OPENPENCIL_COLLAB_LOCATOR_HOME_REGION: $region" >/dev/null
printf '%s\n' "$locator_config" |
grep -F 'OPENPENCIL_COLLAB_LOCATOR_CLIENT_RATE_PER_SECOND: "100"' \
>/dev/null
if [ "$(printf '%s\n' "$locator_config" |
grep -Ec '^[[:space:]]+host_ip:')" -ne 1 ] ||
printf '%s\n' "$locator_config" |
grep -E '^[[:space:]]+host_ip:' |
grep -Fv "host_ip: $bind" >/dev/null
then
echo "resolved locator bind is not the one immutable regional address" >&2
exit 1
fi
relay_config=$(
OPENPENCIL_COLLAB_RELAY_HOME_REGION=$hostile_region \
OPENPENCIL_COLLAB_RELAY_HOST_BIND=0.0.0.0 \
OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE=1 \
OPENPENCIL_COLLAB_POLICY_HOST_FILE=/dev/null \
OPENPENCIL_RELAY_LOCATOR_KEYS_HOST_FILE=/dev/null \
OPENPENCIL_RELAY_X25519_KEYS_HOST_FILE=/dev/null \
docker compose \
-f "$relay_compose" -f "$relay_production" \
-f "$relay_overlay" config
)
printf '%s\n' "$relay_config" | grep -F "host_ip: $bind" >/dev/null
printf '%s\n' "$relay_config" | grep -F 'target: 8091' >/dev/null
printf '%s\n' "$relay_config" | grep -F 'published: "8091"' >/dev/null
printf '%s\n' "$relay_config" |
grep -F "OPENPENCIL_COLLAB_RELAY_HOME_REGION: $region" >/dev/null
printf '%s\n' "$relay_config" |
grep -F 'OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE: "1024"' \
>/dev/null
if [ "$(printf '%s\n' "$relay_config" |
grep -Ec '^[[:space:]]+host_ip:')" -ne 1 ] ||
printf '%s\n' "$relay_config" |
grep -E '^[[:space:]]+host_ip:' |
grep -Fv "host_ip: $bind" >/dev/null
then
echo "resolved relay bind is not the one immutable regional address" >&2
exit 1
fi
done
fi
echo "collab relay locator deployment validation passed"

View file

@ -0,0 +1,151 @@
#!/bin/sh
set -eu
LC_ALL=C
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL PATH
runtime_dir=/usr/local/libexec/openpencil-collab-cn-firewall
config_file=/etc/openpencil/collab-cn-firewall.env
binding_file=/etc/openpencil/collab-cn-firewall-compose.env
chain_name=OPENPENCIL-CN-INGRESS
anchor_comment=openpencil-cn-ingress-v1
allow_relay_comment=openpencil-cn-allow-8091-v1
drop_relay_comment=openpencil-cn-drop-8091-v1
allow_locator_comment=openpencil-cn-allow-8092-v1
drop_locator_comment=openpencil-cn-drop-8092-v1
fallthrough_comment=openpencil-cn-fallthrough-v1
if [ "$(id -u)" -ne 0 ]; then
echo "CN firewall: kernel verification requires root" >&2
exit 1
fi
for command_name in stat ip iptables-save awk flock; do
command -v "$command_name" >/dev/null 2>&1 || {
echo "CN firewall: required command is unavailable: $command_name" >&2
exit 1
}
done
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
common_file="$script_dir/cn-docker-user-firewall-common.sh"
reference_checker="$script_dir/check-cn-docker-user-firewall-references.awk"
address_checker="$script_dir/check-cn-docker-user-firewall-interface-address.awk"
managed_chain_checker="$script_dir/check-cn-docker-user-firewall-managed-chain.awk"
if [ "$script_dir" != "$runtime_dir" ] || [ -L "$script_dir" ] ||
[ "$(stat -c '%u:%g:%a' -- "$script_dir")" != '0:0:755' ] ||
[ -L "$0" ] || [ "$(stat -c '%u:%g:%a' -- "$0")" != '0:0:755' ] ||
[ ! -f "$common_file" ] || [ -L "$common_file" ] ||
[ "$(stat -c '%u:%g:%a' -- "$common_file")" != '0:0:644' ] ||
[ ! -f "$reference_checker" ] || [ -L "$reference_checker" ] ||
[ "$(stat -c '%u:%g:%a' -- "$reference_checker")" != '0:0:644' ] ||
[ ! -f "$address_checker" ] || [ -L "$address_checker" ] ||
[ "$(stat -c '%u:%g:%a' -- "$address_checker")" != '0:0:644' ] ||
[ ! -f "$managed_chain_checker" ] || [ -L "$managed_chain_checker" ] ||
[ "$(stat -c '%u:%g:%a' -- "$managed_chain_checker")" != '0:0:644' ]
then
echo "CN firewall: installed runtime must be immutable root-owned files" >&2
exit 1
fi
. "$common_file"
case ${1-} in
'') cn_fw_verify_mode=kernel; cn_fw_verify_config=$config_file ;;
--pre-docker)
if [ "$#" -ne 1 ]; then
echo "usage: $0 [--pre-docker|--config-only ROOT_OWNED_INVENTORY_FILE]" >&2
exit 2
fi
cn_fw_verify_mode=pre-docker
cn_fw_verify_config=$config_file
;;
--config-only)
if [ "$#" -ne 2 ]; then
echo "usage: $0 [--pre-docker|--config-only ROOT_OWNED_INVENTORY_FILE]" >&2
exit 2
fi
cn_fw_verify_mode=config-only
cn_fw_verify_config=$2
;;
*)
echo "usage: $0 [--pre-docker|--config-only ROOT_OWNED_INVENTORY_FILE]" >&2
exit 2
;;
esac
cn_firewall_load_config "$cn_fw_verify_config" strict
if [ "$cn_fw_verify_mode" = config-only ]; then
echo "root-owned CN firewall inventory is valid"
exit 0
fi
cn_firewall_load_deployment_binding "$binding_file"
if ! ip link show dev "$OPENPENCIL_CN_INGRESS_INTERFACE" >/dev/null 2>&1; then
cn_firewall_fail "configured ingress interface does not exist"
exit 1
fi
cn_fw_interface_addresses=$(ip -4 -o addr show dev \
"$OPENPENCIL_CN_INGRESS_INTERFACE") || {
cn_firewall_fail "could not inspect configured ingress-interface addresses"
exit 1
}
cn_fw_service_assignments=$(printf '%s\n' "$cn_fw_interface_addresses" | awk \
-v expected_ipv4="$OPENPENCIL_CN_SERVICE_IPV4" -f "$address_checker")
if [ "$cn_fw_service_assignments" -ne 1 ]; then
cn_firewall_fail \
"configured service IPv4 must be assigned exactly once to the ingress interface"
exit 1
fi
exec 9>/run/openpencil-collab-cn-firewall.lock
flock -s 9
filter_snapshot=$(iptables-save -t filter)
anchor_summary=$(printf '%s\n' "$filter_snapshot" | awk \
-v managed_chain="$chain_name" -v anchor_comment="$anchor_comment" \
-f "$reference_checker")
if [ "$anchor_summary" != '1 1 1 0' ]; then
cn_firewall_fail \
"managed chain requires one first DOCKER-USER jump and no foreign jump/goto references"
exit 1
fi
if [ "$cn_fw_verify_mode" = kernel ]; then
forward_summary=$(printf '%s\n' "$filter_snapshot" | awk '
$1 == "-A" && $2 == "FORWARD" {
forward_rules++;
jump = "";
for (i = 3; i <= NF; i++) {
if ($i == "-j" && i < NF) jump = $(i + 1);
}
if (jump == "DOCKER-USER") {
targets++;
if (NF == 4 && $3 == "-j" && $4 == "DOCKER-USER") {
canonical++;
}
if (forward_rules == 1) first = 1;
}
}
END { print targets + 0, canonical + 0, first + 0 }
')
if [ "$forward_summary" != '1 1 1' ]; then
cn_firewall_fail \
"Docker must place one first-position DOCKER-USER jump in FORWARD"
exit 1
fi
fi
managed_summary=$(printf '%s\n' "$filter_snapshot" | awk \
-v managed_chain="$chain_name" \
-v ingress_interface="$OPENPENCIL_CN_INGRESS_INTERFACE" \
-v gateway_ipv4="$OPENPENCIL_CN_GATEWAY_SOURCE_IPV4" \
-v service_ipv4="$OPENPENCIL_CN_SERVICE_IPV4" \
-v allow_relay_comment="$allow_relay_comment" \
-v drop_relay_comment="$drop_relay_comment" \
-v allow_locator_comment="$allow_locator_comment" \
-v drop_locator_comment="$drop_locator_comment" \
-v fallthrough_comment="$fallthrough_comment" \
-f "$managed_chain_checker")
if [ "$managed_summary" != '5 5 5 0' ]; then
cn_firewall_fail "managed chain predicates, order, or rule count are not exact"
exit 1
fi
echo "active CN Docker ingress firewall verified"

View file

@ -108,6 +108,12 @@ Private encodings, shared secrets, and derived keys are zeroized.
## Deployment
On Unix, the public signed-policy file may be owned either by the relay's
effective UID or by root. It must be a regular non-symlink file and must not be
group- or world-writable. Because the policy contains public verification
material, root-owned mode `0440` (when the container identity has group read)
or `0444` is accepted; writable modes fail closed.
Run full challenge-bound production mode:
```sh
@ -119,9 +125,13 @@ sudo chmod 0400 "$OPENPENCIL_RELAY_X25519_KEYS_HOST_FILE"
docker compose \
-f deploy/collab-relay/compose.yaml \
-f deploy/collab-relay/compose.production.yaml \
-f deploy/collab-relay/compose.production.global.yaml \
up --build
```
That command is for Global. On CN, replace only the final overlay with
`compose.production.cn.yaml`. Never combine the two regional overlays.
The secret is mounted read-only under `/run/secrets`. The packaged image runs
as UID/GID `65532`, so the host file must actually be owner-readable by that
identity and inaccessible to group/other users. Docker Compose silently
@ -167,9 +177,86 @@ docker compose \
Never expose that development mode to the Internet.
## China relay for overseas peers
## Direct regional public paths
For a locator with `home_region=cn`, domestic clients connect to the normal CN
The default two-region deployment uses one public application host per region,
with both collaboration paths on that host:
```text
CN https://<cn-public-host>/v1/locator
wss://<cn-public-host>/v1/tunnel
Global https://<global-public-host>/v1/locator
wss://<global-public-host>/v1/tunnel
```
Keep the concrete public hosts in the private deployment inventory and signed
desktop bootstrap, not in this public repository. The two Compose projects use
separate networks, so host Nginx must never use Docker service names such as
`relay` or `locator` as upstreams. The common Compose files publish no host
ports. Immutable regional overlays bind both the home region and the only
permitted host address; there is no host-bind variable or wildcard default.
They also fix `OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE=1024`, equal to
the process-wide pending ceiling. The common Compose files do not set this
production override.
That equality is intentional. A connection crossing a Docker-published host
port reaches the relay with a Docker NAT peer address, so independent Internet
clients can collapse into one application-visible source. The relay must not
apply its normal per-source bucket to that shared address. Host Nginx is the
trusted real-source boundary: its handshake-rate and connection zones enforce
per-client limits before proxying, while the relay still enforces the global
1024-pending ceiling. If another load balancer precedes Nginx, restore the
client address only from its fixed trusted addresses; never trust arbitrary
forwarded-address headers.
On the Global application host, use only:
```text
deploy/collab-relay/compose.production.global.yaml
deploy/collab-relay-locator/compose.production.global.yaml
```
They hard-code `home_region=global` and loopback ports `127.0.0.1:8091` and
`127.0.0.1:8092`. At Global Nginx `http` scope include
`../collab-relay-locator/nginx-http-limits.conf`,
`nginx-http-direct.conf`, and
`../collab-relay-locator/nginx-http-direct.conf` exactly once.
On the CN application service host, use only:
```text
deploy/collab-relay/compose.production.cn.yaml
deploy/collab-relay-locator/compose.production.cn.yaml
```
They hard-code `home_region=cn` and private ports `10.0.0.10:8091` and
`10.0.0.10:8092`. The CN front gateway includes the same locator limits plus
`nginx-http-direct-cn-gateway.conf` and
`../collab-relay-locator/nginx-http-direct-cn-gateway.conf` at `http` scope.
Those upstreams are explicitly `10.0.0.10:8091` and `10.0.0.10:8092`.
Restrict both ports on the service-host firewall to the configured front
gateway source addresses. Never bind either port to `0.0.0.0` or expose it on
a public interface. Do not copy a regional overlay to the other region, combine
the two overlays, or add a Compose `ports` override.
Inside each regional application TLS virtual host include
`../collab-relay-locator/nginx-location-direct.conf` and
`nginx-location.conf`. These files intentionally define only the two exact
collaboration paths, so they do not replace the application's normal routes.
The standalone relay `nginx.conf` and dedicated-host locator
`nginx-location.conf` must not be combined with the direct-host snippets.
A Global user who explicitly selects CN uses the CN application host directly
for both paths. The signed locator remains `home_region=cn`; the Global relay
is not a fallback and does not proxy that session. This topology does not
require an L4 federation edge. Confirm the two externally published URL pairs
and their certificates from the private inventory before rollout.
## Optional China relay for overseas peers
This optional topology is not part of the direct regional deployment above.
Use it only after separately deploying and reviewing the L4 edge. For a
locator with `home_region=cn`, domestic clients connect to the normal CN
WSS endpoint. Overseas clients may instead resolve a Global ingress that is
only an L4 passthrough:

View file

@ -0,0 +1,10 @@
services:
relay:
environment:
OPENPENCIL_COLLAB_RELAY_HOME_REGION: cn
OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE: "1024"
ports:
- target: 8091
published: "8091"
host_ip: 10.0.0.10
protocol: tcp

View file

@ -0,0 +1,10 @@
services:
relay:
environment:
OPENPENCIL_COLLAB_RELAY_HOME_REGION: global
OPENPENCIL_COLLAB_RELAY_MAX_PENDING_PER_SOURCE: "1024"
ports:
- target: 8091
published: "8091"
host_ip: 127.0.0.1
protocol: tcp

View file

@ -3,7 +3,6 @@ services:
command:
- --production
environment:
OPENPENCIL_COLLAB_RELAY_HOME_REGION: cn
OPENPENCIL_COLLAB_RELAY_TICKET_POLICY_FILE: /run/secrets/openpencil-collab-policy.json
OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS_FILE: /run/secrets/openpencil-relay-locator-keys.json
OPENPENCIL_COLLAB_RELAY_X25519_KEYS_FILE: /run/secrets/openpencil-relay-x25519-keys.json

View file

@ -0,0 +1,15 @@
# Include exactly once in the CN front gateway's Nginx `http` block. The CN
# application service host is the fixed private address 10.0.0.10.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=relay_handshakes:10m rate=30r/m;
limit_conn_zone $binary_remote_addr zone=relay_connections:10m;
upstream openpencil_collab_relay {
server 10.0.0.10:8091;
keepalive 64;
}

View file

@ -0,0 +1,16 @@
# Include exactly once in the Nginx `http` block when the relay paths share an
# application TLS virtual host. The standalone nginx.conf already declares
# equivalent resources and must not be combined with this snippet.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=relay_handshakes:10m rate=30r/m;
limit_conn_zone $binary_remote_addr zone=relay_connections:10m;
upstream openpencil_collab_relay {
server 127.0.0.1:8091;
keepalive 64;
}

View file

@ -0,0 +1,27 @@
# Include inside the regional application TLS server after including
# the matching Global-host or CN-front-gateway `nginx-http-direct*.conf` once
# at `http` scope. The Compose host bind must match that topology.
location = /v1/tunnel {
if ($request_uri != "/v1/tunnel") {
return 404;
}
if ($http_host = "") {
return 400;
}
access_log off;
limit_req zone=relay_handshakes burst=10 nodelay;
limit_conn relay_connections 8;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Forwarded-Proto https;
proxy_pass_header OpenPencil-Relay-Challenge;
proxy_buffering off;
proxy_read_timeout 43210s;
proxy_send_timeout 43210s;
proxy_pass http://openpencil_collab_relay;
}

View file

@ -49,6 +49,9 @@ for required in \
crates/op-collab-relay-control-plane/LICENSE \
crates/op-collab-policy-file/Cargo.toml \
crates/op-collab-policy-file/LICENSE \
crates/op-collab-relay-locator-hsm/Cargo.toml \
crates/op-collab-relay-locator-hsm/LICENSE \
crates/op-collab-relay-locator-hsm/tests/softhsm.rs \
crates/op-collab-relay-locator-server/Cargo.toml \
crates/op-collab-relay-locator-server/LICENSE \
crates/op-collab-smoke/LICENSE \
@ -80,6 +83,12 @@ for required in \
deploy/collab-relay-locator/nginx-http-limits.conf \
deploy/collab-relay-locator/nginx-location.conf \
deploy/collab-relay-locator/validate.sh \
deploy/collab-relay-locator-hsm/Dockerfile \
deploy/collab-relay-locator-hsm/README.md \
deploy/collab-relay-locator-hsm/compose.yaml \
deploy/collab-relay-locator-hsm/config.example.json \
deploy/collab-relay-locator-hsm/openpencil-locator-hsm.conf \
deploy/collab-relay-locator-hsm/softhsm2.conf \
deploy/collab-relay-locator-edge/README.md \
deploy/collab-relay-locator-edge/global-nginx.conf \
deploy/collab-relay-locator-edge/cn-federation-nginx.conf \
@ -134,6 +143,7 @@ for workflow_path in \
"crates/op-collab-relay-server/**" \
"crates/op-collab-relay-control-plane/**" \
"crates/op-collab-policy-file/**" \
"crates/op-collab-relay-locator-hsm/**" \
"crates/op-collab-relay-locator-server/**" \
"crates/op-util/**" \
"crates/op-editor-core/**" \
@ -146,7 +156,10 @@ for workflow_path in \
"deploy/collab-relay/**" \
"deploy/collab-relay-edge/**" \
"deploy/collab-relay-locator/**" \
"deploy/collab-relay-locator-edge/**"; do
"deploy/collab-relay-locator-hsm/**" \
"deploy/collab-relay-locator-edge/**" \
"tools/check-collab-security-boundaries-cases.sh" \
"tools/check-collab-deployment-boundaries.sh"; do
require_literal_count .github/workflows/collab-security.yml \
"$workflow_path" 2 "collaboration security workflow path trigger"
done
@ -337,6 +350,22 @@ done
require_literal .github/workflows/collab-security.yml \
"bash deploy/collab-relay-locator/validate.sh" \
"locator deployment workflow validation"
require_literal .github/workflows/collab-security.yml \
"cargo test --locked -p op-collab-relay-locator-hsm" \
"locator HSM crate workflow test"
require_literal .github/workflows/collab-security.yml \
"docker build --target test" \
"real SoftHSM workflow test target"
require_literal .github/workflows/collab-security.yml \
"-f deploy/collab-relay-locator-hsm/Dockerfile ." \
"real SoftHSM workflow test target"
for locator_hsm_test_anchor in \
"FROM build AS test" \
"apt-get install -y --no-install-recommends softhsm2" \
"cargo test --locked -p op-collab-relay-locator-hsm --test softhsm -- --nocapture"; do
require_literal deploy/collab-relay-locator-hsm/Dockerfile \
"$locator_hsm_test_anchor" "real SoftHSM image test boundary"
done
for locator_edge_global_anchor in \
"listen 8443;" \

View file

@ -1,6 +1,86 @@
# Mutation cases for check-collab-security-boundaries.test.sh.
# Sourced after the fixture and assertion helpers have been initialized.
write_collab_security_workflow_fixture() {
cat > "$fixture_root/.github/workflows/collab-security.yml" <<'EOF'
pull_request:
paths:
- '.dockerignore'
- '.gitignore'
- 'crates/op-collab-smoke/**'
- 'crates/op-collab-relay-protocol/**'
- 'crates/op-collab-relay-client/**'
- 'crates/op-collab-relay-server/**'
- 'crates/op-collab-relay-control-plane/**'
- 'crates/op-collab-policy-file/**'
- 'crates/op-collab-relay-locator-hsm/**'
- 'crates/op-collab-relay-locator-server/**'
- 'crates/op-util/**'
- 'crates/op-editor-core/**'
- 'crates/op-editor-host-core/**'
- 'crates/op-editor-ui/**'
- 'crates/op-host-native/**'
- 'crates/op-host-desktop/**'
- 'crates/op-host-services/**'
- 'crates/op-i18n/**'
- 'deploy/collab-relay/**'
- 'deploy/collab-relay-edge/**'
- 'deploy/collab-relay-locator/**'
- 'deploy/collab-relay-locator-hsm/**'
- 'deploy/collab-relay-locator-edge/**'
- 'tools/check-collab-security-boundaries-cases.sh'
- 'tools/check-collab-deployment-boundaries.sh'
- 'tools/check-op-auth-prebuilt.sh'
- 'tools/check-op-auth-prebuilt.test.sh'
- 'tools/package-op-auth-prebuilt.sh'
push:
paths:
- '.dockerignore'
- '.gitignore'
- 'crates/op-collab-smoke/**'
- 'crates/op-collab-relay-protocol/**'
- 'crates/op-collab-relay-client/**'
- 'crates/op-collab-relay-server/**'
- 'crates/op-collab-relay-control-plane/**'
- 'crates/op-collab-policy-file/**'
- 'crates/op-collab-relay-locator-hsm/**'
- 'crates/op-collab-relay-locator-server/**'
- 'crates/op-util/**'
- 'crates/op-editor-core/**'
- 'crates/op-editor-host-core/**'
- 'crates/op-editor-ui/**'
- 'crates/op-host-native/**'
- 'crates/op-host-desktop/**'
- 'crates/op-host-services/**'
- 'crates/op-i18n/**'
- 'deploy/collab-relay/**'
- 'deploy/collab-relay-edge/**'
- 'deploy/collab-relay-locator/**'
- 'deploy/collab-relay-locator-hsm/**'
- 'deploy/collab-relay-locator-edge/**'
- 'tools/check-collab-security-boundaries-cases.sh'
- 'tools/check-collab-deployment-boundaries.sh'
- 'tools/check-op-auth-prebuilt.sh'
- 'tools/check-op-auth-prebuilt.test.sh'
- 'tools/package-op-auth-prebuilt.sh'
steps:
- run: bash tools/check-op-auth-prebuilt.sh
- run: bash tools/check-op-auth-prebuilt.test.sh
- run: bash -n tools/package-op-auth-prebuilt.sh
- run: cargo test --locked -p op-auth-bridge --test prebuilt_provenance
- run: cargo test --locked -p op-collab-transport
- run: cargo test --locked -p op-collab-transport config::tests
- run: cargo test --locked -p op-collab-transport frame::tests
- run: cargo test --locked -p op-collab-relay-locator-hsm
- run: |
docker build --target test \
-f deploy/collab-relay-locator-hsm/Dockerfile .
- run: bash deploy/collab-relay-edge/validate.sh
- run: bash deploy/collab-relay-locator/validate.sh
- run: bash deploy/collab-relay-locator-edge/validate.sh
EOF
}
new_fixture baseline
expect_pass "accepts the minimal safe collaboration boundary"
@ -134,6 +214,45 @@ mv \
expect_failure "requires the credential transport codec workflow test" \
"credential transport codec workflow test"
new_fixture complete-transport-workflow-test-removed
sed '/cargo test --locked -p op-collab-transport$/d' \
"$fixture_root/.github/workflows/collab-security.yml" \
> "$fixture_root/.github/workflows/collab-security.yml.next"
mv \
"$fixture_root/.github/workflows/collab-security.yml.next" \
"$fixture_root/.github/workflows/collab-security.yml"
expect_failure "requires the complete transport resource-limit test suite" \
"complete transport resource-limit workflow test"
new_fixture locator-hsm-workflow-tests-removed
sed \
-e '/cargo test --locked -p op-collab-relay-locator-hsm/d' \
-e '/docker build --target test/d' \
"$fixture_root/.github/workflows/collab-security.yml" \
> "$fixture_root/.github/workflows/collab-security.yml.next"
mv \
"$fixture_root/.github/workflows/collab-security.yml.next" \
"$fixture_root/.github/workflows/collab-security.yml"
expect_failure "requires locator HSM unit and real SoftHSM workflow tests" \
"locator HSM crate workflow test"
new_fixture locator-hsm-soft-token-target-removed
sed 's/docker build --target test/docker build/' \
"$fixture_root/.github/workflows/collab-security.yml" \
> "$fixture_root/.github/workflows/collab-security.yml.next"
mv \
"$fixture_root/.github/workflows/collab-security.yml.next" \
"$fixture_root/.github/workflows/collab-security.yml"
expect_failure "requires the real SoftHSM Docker test stage" \
"real SoftHSM workflow test target"
new_fixture locator-hsm-production-seed
printf '%s\n' \
'const PRODUCTION_SIGNING_SEED: [u8; 32] = [9; 32];' \
>> "$fixture_root/crates/op-collab-relay-locator-hsm/src/lib.rs"
expect_failure "scans the locator HSM crate for deterministic production keys" \
"deterministic signing/key seed leaked"
new_fixture desktop-renewal-vec-copy
printf '%s\n' \
'fn bad(ticket: Ticket) { let _ = ticket.expose().as_bytes().to_vec(); }' \

View file

@ -24,6 +24,7 @@ collab_scan_roots=(
crates/op-collab-relay-server
crates/op-collab-relay-control-plane
crates/op-collab-policy-file
crates/op-collab-relay-locator-hsm
crates/op-collab-relay-locator-server
crates/op-collab-smoke
crates/op-auth-bridge
@ -42,6 +43,7 @@ collab_scan_roots=(
deploy/collab-relay
deploy/collab-relay-edge
deploy/collab-relay-locator
deploy/collab-relay-locator-hsm
deploy/collab-relay-locator-edge
)
@ -602,6 +604,7 @@ untyped_errors=$(grep -RInE \
crates/op-collab-relay-server/src \
crates/op-collab-relay-control-plane/src \
crates/op-collab-policy-file/src \
crates/op-collab-relay-locator-hsm/src \
crates/op-collab-relay-locator-server/src \
crates/op-auth-bridge/src 2>/dev/null || true)
if [[ -n "$untyped_errors" ]]; then

View file

@ -43,6 +43,8 @@ new_fixture() {
"$fixture_root/crates/op-collab-relay-server/src" \
"$fixture_root/crates/op-collab-relay-control-plane/src" \
"$fixture_root/crates/op-collab-policy-file/src" \
"$fixture_root/crates/op-collab-relay-locator-hsm/src" \
"$fixture_root/crates/op-collab-relay-locator-hsm/tests" \
"$fixture_root/crates/op-collab-relay-locator-server/src" \
"$fixture_root/crates/op-collab-smoke/src" \
"$fixture_root/crates/op-auth-bridge/src" \
@ -52,19 +54,21 @@ new_fixture() {
"$fixture_root/crates/op-editor-host-core/src/collab" \
"$fixture_root/crates/op-editor-ui/src" \
"$fixture_root/crates/op-host-native/src" \
"$fixture_root/crates/op-host-desktop/src" \
"$fixture_root/crates/op-collab-host/src/runtime/network" \
"$fixture_root/crates/op-host-desktop/src/collab_runtime" \
"$fixture_root/crates/op-host-desktop/src/collab_runtime/network" \
"$fixture_root/crates/op-host-services/src" \
"$fixture_root/crates/op-i18n/src" \
"$fixture_root/deploy/collab-relay" \
"$fixture_root/deploy/collab-relay-edge" \
"$fixture_root/deploy/collab-relay-locator" \
"$fixture_root/deploy/collab-relay-locator-hsm" \
"$fixture_root/deploy/collab-relay-locator-edge"
cp "$gate_source" "$fixture_root/tools/check-collab-security-boundaries.sh"
cp "$script_dir/check-collab-security-boundaries-cases.sh" \
"$fixture_root/tools/check-collab-security-boundaries-cases.sh"
cp "$script_dir/check-collab-deployment-boundaries.sh" \
"$fixture_root/tools/check-collab-deployment-boundaries.sh"
cp "$script_dir/check-collab-security-boundaries-cases.sh" "$fixture_root/tools/"
cp "$script_dir/check-op-auth-prebuilt.sh" "$fixture_root/tools/check-op-auth-prebuilt.sh"
cp "$script_dir/check-op-auth-prebuilt.test.sh" "$fixture_root/tools/check-op-auth-prebuilt.test.sh"
cp "$script_dir/package-op-auth-prebuilt.sh" "$fixture_root/tools/package-op-auth-prebuilt.sh"
@ -94,8 +98,7 @@ EOF
This file exists so the executable boundary gate can verify its public contract.
EOF
cp "$script_dir/../.github/workflows/collab-security.yml" \
"$fixture_root/.github/workflows/collab-security.yml"
write_collab_security_workflow_fixture
cat > "$fixture_root/deploy/collab-relay-edge/global-nginx.conf" <<'EOF'
stream {
@ -232,24 +235,6 @@ location = /v1/locator {
client_max_body_size 191;
proxy_set_header Authorization $http_authorization;
}
location = /v1/pairing-code {
if ($request_uri != "/v1/pairing-code") { return 404; }
limit_except POST { deny all; }
client_max_body_size 624; client_body_buffer_size 624;
if ($http_content_type != "application/vnd.openpencil.relay-pairing-publish-v1") { return 415; }
proxy_pass_request_headers off;
proxy_pass http://locator:8092/v1/pairing-code;
}
location = /v1/pairing-code/claim {
if ($request_uri != "/v1/pairing-code/claim") { return 404; }
limit_except POST { deny all; }
client_max_body_size 49; client_body_buffer_size 49;
if ($content_length != "49") { return 400; }
if ($http_content_type != "application/vnd.openpencil.relay-pairing-claim-v1") { return 415; }
if ($http_accept != "application/vnd.openpencil.relay-sealed-invite-v1") { return 406; }
proxy_pass_request_headers off;
proxy_pass http://locator:8092/v1/pairing-code/claim;
}
location / {
return 404;
}
@ -282,6 +267,22 @@ EOF
set -eu
EOF
cat > "$fixture_root/deploy/collab-relay-locator-hsm/Dockerfile" <<'EOF'
FROM rust:1.94-bookworm@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa AS build
FROM build AS test
RUN apt-get update && apt-get install -y --no-install-recommends softhsm2
RUN cargo test --locked -p op-collab-relay-locator-hsm --test softhsm -- --nocapture
FROM debian:bookworm-slim@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
EOF
for locator_hsm_file in \
README.md \
compose.yaml \
config.example.json \
openpencil-locator-hsm.conf \
softhsm2.conf; do
: > "$fixture_root/deploy/collab-relay-locator-hsm/$locator_hsm_file"
done
cat > "$fixture_root/deploy/collab-relay-locator-edge/global-nginx.conf" <<'EOF'
stream {
access_log off;
@ -331,33 +332,11 @@ server {
if ($request_uri != "/v1/locator") {
return 404;
}
limit_except POST {
deny all;
}
proxy_pass_request_headers off;
proxy_set_header Authorization $http_authorization;
proxy_set_header Transfer-Encoding "";
proxy_set_header Content-Encoding "";
}
location = /v1/pairing-code {
if ($request_uri != "/v1/pairing-code") { return 404; }
limit_except POST { deny all; }
client_max_body_size 624; client_body_buffer_size 624;
if ($http_content_type != "application/vnd.openpencil.relay-pairing-publish-v1") { return 415; }
proxy_pass_request_headers off;
proxy_pass http://openpencil_locator/v1/pairing-code;
}
location = /v1/pairing-code/claim {
if ($request_uri != "/v1/pairing-code/claim") { return 404; }
limit_except POST { deny all; }
client_max_body_size 49; client_body_buffer_size 49;
if ($content_length != "49") { return 400; }
if ($http_content_type != "application/vnd.openpencil.relay-pairing-claim-v1") { return 415; }
if ($http_accept != "application/vnd.openpencil.relay-sealed-invite-v1") { return 406; }
proxy_pass_request_headers off;
proxy_pass http://openpencil_locator/v1/pairing-code/claim;
}
location / { return 404; }
}
EOF
for locator_edge_file in \
@ -434,6 +413,7 @@ EOF
op-collab-relay-server \
op-collab-relay-control-plane \
op-collab-policy-file \
op-collab-relay-locator-hsm \
op-collab-relay-locator-server; do
cat > "$fixture_root/crates/$relay_crate/Cargo.toml" <<EOF
[package]
@ -443,6 +423,7 @@ license.workspace = true
EOF
: > "$fixture_root/crates/$relay_crate/LICENSE"
done
: > "$fixture_root/crates/op-collab-relay-locator-hsm/tests/softhsm.rs"
: > "$fixture_root/crates/op-collab-smoke/LICENSE"
cat > "$fixture_root/crates/op-auth-bridge/Cargo.toml" <<'EOF'
@ -562,29 +543,6 @@ EOF
fn presence_payload_limit_applies_to_encode_and_decode() {}
#[test]
fn oversized_snapshot_kind_cannot_raise_the_owner_inbound_ceiling() {}
EOF
cat > "$fixture_root/crates/op-collab-transport/src/admission.rs" <<'EOF'
pub enum PeerIdentityPolicy {
ThisAccount,
AnyIssuedAccount,
}
#[cfg(test)]
#[path = "admission_tests.rs"]
mod admission_tests;
EOF
cat > "$fixture_root/crates/op-collab-transport/src/admission_tests.rs" <<'EOF'
#[test]
fn any_issued_account_admits_a_foreign_subject_but_keeps_every_other_check() {}
#[test]
fn an_unpinned_join_without_confirmation_still_requires_this_account() {}
#[test]
fn an_unpinned_join_admits_a_foreign_account_only_behind_the_confirmation_gate() {}
EOF
cat > "$fixture_root/crates/op-auth-bridge/src/collab_relay_token.rs" <<'EOF'
pub struct VerifiedRelayTokenClaims;
EOF
cat > "$fixture_root/crates/op-collab-transport/src/config.rs" <<'EOF'
@ -641,6 +599,18 @@ impl QueueItem {
pub(crate) struct BoundedTransferQueue;
pub struct SharedQueueBudget;
pub struct TokenBucket;
EOF
cat > "$fixture_root/crates/op-collab-transport/src/admission.rs" <<'EOF'
pub enum PeerIdentityPolicy {
ThisAccount,
AnyIssuedAccount,
}
EOF
cat > "$fixture_root/crates/op-collab-transport/src/admission_tests.rs" <<'EOF'
#[test]
fn any_issued_account_admits_a_foreign_subject_but_keeps_every_other_check() {}
EOF
cat > "$fixture_root/crates/op-auth-bridge/src/collab_jwks_cache.rs" <<'EOF'
@ -665,6 +635,10 @@ EOF
cat > "$fixture_root/crates/op-auth-bridge/src/collab_ticket.rs" <<'EOF'
pub const MAX_COLLAB_TICKET_BYTES: usize = 1024;
EOF
cat > "$fixture_root/crates/op-auth-bridge/src/collab_relay_token.rs" <<'EOF'
pub struct VerifiedRelayTokenClaims;
EOF
cat > "$fixture_root/crates/op-auth-bridge/build.rs" <<'EOF'
@ -757,20 +731,28 @@ EOF
pub const MAX_AVATAR_SOURCE_PIXELS: u64 = 1_048_576;
EOF
cat > "$fixture_root/crates/op-collab-host/src/runtime/types.rs" <<'EOF'
cat > "$fixture_root/crates/op-host-desktop/src/collab_runtime/types.rs" <<'EOF'
assert_not_impl_any!(OwnerNetworkCommand: Clone);
assert_not_impl_any!(GuestNetworkCommand: Clone);
assert_not_impl_any!(PeerNetworkCommand: Clone);
fn verification_commands_move_the_original_ticket_allocation() {}
EOF
cat > "$fixture_root/crates/op-collab-host/src/runtime/relay_bootstrap_tests.rs" <<'EOF'
cat > "$fixture_root/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_tests.rs" <<'EOF'
#[test]
fn payload_rejects_exact_cross_region_key_reuse() {}
#[cfg(test)]
fn an_unpinned_join_without_confirmation_still_requires_this_account() {}
#[cfg(test)]
fn an_unpinned_join_admits_a_foreign_account_only_behind_the_confirmation_gate() {}
EOF
cat > "$fixture_root/crates/op-collab-host/src/runtime/network/owner.rs" <<'EOF'
fn admission_policy() { let _ = PeerIdentityPolicy::AnyIssuedAccount; }
cat > "$fixture_root/crates/op-host-desktop/src/collab_runtime/network/owner.rs" <<'EOF'
fn owner_policy() {
let _ = PeerIdentityPolicy::AnyIssuedAccount;
}
EOF
ln -s "$script_dir/check-collab-security-boundaries.test.sh" \