Skip to content

Latest commit

 

History

History
267 lines (211 loc) · 12.7 KB

File metadata and controls

267 lines (211 loc) · 12.7 KB

Sharing the Server with Trusted Peers over Tailscale

How to let a few trusted people on other networks send their Amplifier sessions to your context-intelligence server, exposing the minimum possible: a single endpoint, over an encrypted overlay, with nothing else reachable and no public internet exposure.

This guide complements the existing options — it is the path for sharing with a handful of external people without standing up a public endpoint:

Goal Use
Lock a public/LAN port to trusted source IPs README → "Network Access and Security" (firewall rule)
Local/dev HTTPS in front of the server docs/service-setup.md §10 (Caddy)
Production, public, managed TLS docs/azure-deployment.md
Share with a few external peers, privately this guide

Note: This guide uses Tailscale as the overlay network. The same principles (bind to loopback, expose one path, scope per peer) apply to any WireGuard-based mesh.


1. What a peer's client actually needs

The Amplifier client hook sends exactly one kind of request:

POST /events     Authorization: Bearer <API_KEY>

It never connects to Neo4j (bolt 7687 / browser 7474) and uses no other endpoint. So a peer needs reachability to one path on one portPOST /events — and nothing else. The entire design below follows from that fact.

The peer holds a raw bearer token; the server stores only its SHA-256 digest and verifies each request by hashing the presented token (sha256(token)) and looking the digest up. Issue each peer their own token via the api_keys keystore — see docs/managing-api-keys.md. Keep auth enabled; it is your application-layer gate even inside the tunnel.


2. Architecture (least access)

peer's Amplifier ──WireGuard──► tailscale serve (HTTPS :443)
                                  mount: ONLY /events ──► http://127.0.0.1:8000/events
                                  (every other path → 404, never proxied)
server (gunicorn) bound 127.0.0.1:8000 only   ·   Neo4j bound to loopback, never shared

A single tailscale serve mount does two jobs: it terminates TLS (with an automatic MagicDNS certificate) and acts as a path allowlist. The second job matters because a few server endpoints are intentionally unauthenticated. The server is headless (API-only), so this is a single fixed set — /status, /version, /docs, /openapi.json. That exempt set is defined in code and is not configurable, so the only way to keep those endpoints away from peers is to not route them — which scoping serve to /events does cleanly.

The path allowlist is the read boundary. Treat it as load-bearing. It matters for a second, larger reason than the unauthenticated endpoints: the server has no write-only credential. require_read returns early for any principal that passes require_write, and every static/human principal is write-capable by construction (context_intelligence_server/authz.py). So a peer's /events token is equally able to call POST /cypher — including {"workspace": "*"}, which skips workspace filtering entirely — and GET /blobs/{session_id}, which has no owner check at all. Nothing in the application layer stops it.

Not routing those paths is the only thing that makes a peer contribute-only. If you later reach this server any other way — the default server_host: 0.0.0.0 LAN bind, a reverse proxy that forwards /, or the public Azure ingress in azure-deployment.mdevery peer token you have already issued becomes a full read credential on the entire graph, across all workspaces. Re-verify with §4's gate after any change to how the server is exposed.


3. Lock the host bindings

By default the server binds 0.0.0.0:8000, and a locally-run Neo4j may listen on 7474/7687. For a shared deployment, close both so Tailscale is the only ingress.

  • Server — set server_host: 127.0.0.1 in your server-config.yaml (loopback only).
  • Neo4j — bind it to loopback in neo4j.conf:
    server.default_listen_address=127.0.0.1
    # server.bolt.listen_address=127.0.0.1:7687
    # server.http.listen_address=127.0.0.1:7474
    The server reaches it at bolt://localhost:7687.

Gate — prove it before continuing. From another machine on your LAN: curl http://<this-host-lan-ip>:8000/status must be refused, and nc -vz <this-host-lan-ip> 7474 7687 must be refused. Only 127.0.0.1 should answer.


4. Expose only /events over the tailnet

tailscale serve --bg --https=443 --set-path=/events http://127.0.0.1:8000/events
tailscale serve status

tailscale serve matches paths like Go's ServeMux: mounting only /events leaves every other path unserved (404). It cannot filter by HTTP method, which is fine — /events requires the bearer token, and other verbs return 405 from the app.

Important — verify the path rewrite. Tailscale's docs don't specify whether --set-path strips the prefix before forwarding. Confirm with a request from a tailnet device: curl -s -o /dev/null -w '%{http_code}' https://<node>.<tailnet>.ts.net/events must return 401 (reached the app's /events, correctly demanding a token). A 404 means it double-prefixed to /events/events — drop the path from the target (…:8000) or adjust --set-path, then re-test.

Important — serve exposes the whole :443 listener. Tailscale ACLs are port-level, not path-level. Any other tailscale serve mount on this node (e.g. a different local app at /) is also reachable by anyone you share the node with. Only share a node whose entire serve surface you're comfortable exposing to those peers. To check: tailscale serve status should show only the /events mount (or only mounts you intend peers to reach).


5. Share the node and scope access — read this section carefully

There are two independent gates: sharing controls which machine a peer can reach; ACL grants control which ports on it.

5a. Share the machine, per peer

In the Tailscale admin console: Machines → (your node) → Share → invite each peer by their Tailscale login email. Each accepts with their own (free) account. Sharing grants access to only that one machine, and is revocable per person.

5b. The ACL lesson that makes or breaks this

The single most important lesson in this guide. A default tailnet policy contains an allow-all grant:

{ "src": ["*"], "dst": ["*"], "ip": ["*"] }

* includes shared/invited external users (it expands to include autogroup:shared), and grants are additive — a more-specific grant never overrides a broader one; the policy engine applies the union. Therefore, if you leave src: ["*"] in place and add a narrow tcp:443 grant for your peers, the peers still get every port on the shared machine. Your narrow grant is cosmetic.

The fix is to make the broad rule not match shared users. Change its source from * to autogroup:member (all direct members of your own tailnet, which excludes shared users) — or to an explicit group of your own users. Then the only grant a shared peer matches is your narrow one.

{
  "hosts": { "ci-server": "100.x.y.z" },   // your node's stable Tailscale IP (or use a tag)

  "grants": [
    // Your own members keep full access. NOTE: src is autogroup:member, NOT "*".
    { "src": ["autogroup:member"], "dst": ["*"], "ip": ["*"] },

    // Shared peers: ONLY tcp:443 on the server, nothing else.
    { "src": ["peer1@example.com", "peer2@example.com"], "dst": ["ci-server"], "ip": ["tcp:443"] }
  ],

  // Optional self-check. CAVEAT: a shared-user email as a test `src` is not
  // documented; if it errors on save, remove it and verify operationally (5c).
  "tests": [
    { "src": "peer1@example.com", "accept": ["ci-server:443"], "deny": ["ci-server:8000"] }
  ]
}

Notes:

  • Name peers explicitly (peer@email) for the tightest, most auditable rule. autogroup:shared also works as a src and auto-includes all current and future shared users — convenient, but broader. (autogroup:shared is valid only as a src, never a dst.)
  • Reference the node in dst via a hosts alias or a tag — MagicDNS device names are not a documented grant selector.
  • Your existing ssh and Funnel (nodeAttrs) rules that key on autogroup:member already exclude shared users — peers get neither.

5c. Operational isolation check (do this once a peer is connected)

From a shared peer's machine: https://<node>.<tailnet>.ts.net/events connects (401 without a token), while the server's other ports (8000, 7474, 7687) and every other host on your tailnet are refused. A non-shared account should be refused entirely.


6. Verification gates (run in order)

# Gate Pass
0 serve path rewrite …/events (no token) → 401, not 404
1 host lockdown LAN/bridge curl to :8000, :7474, :7687 → refused
2 auth POST /events no token → 401; OPTIONS/HEAD /events → 401 (no CORS/405 leak); with token → accepted
3 reboot persistence after reboot, tailscale serve status still shows the /events mount and gate 1 still holds
4 ACL isolation shared account reaches :443 only; non-shared account refused
5 end to end a real Amplifier session via the client lands events in Neo4j (/cypher count increases)

Onboard real peers only after gates 0–5 pass.


7. Back up the graph (and prove the restore)

The shared graph is the whole value; Neo4j Community ships no backup agent. A minimal cold backup:

neo4j stop                                  # graceful flush (do NOT kill -STOP — can corrupt the WAL)
tar czf neo4j-$(date +%F).tgz -C <neo4j-data-dir> .   # e.g. /var/lib/neo4j/data
neo4j start

Restore. Stop Neo4j, replace the data directory from the archive, then start it again. Run as the account that owns the Neo4j data files (the neo4j service user, or your user for a tarball install), or the overwrite fails with Permission denied and the restore silently does nothing:

neo4j stop
rm -rf <neo4j-data-dir>/* && tar xzf neo4j-YYYY-MM-DD.tgz -C <neo4j-data-dir>
neo4j start

Test one restore before you trust it. Also monitor disk — ingested data grows unbounded and there is no ingress size/rate cap.


8. Trust model — decide consciously

This design relies on the peers being trusted. Known, accepted properties for a small group:

Property Implication Harden later by
No write-only credential exists Any data token that can POST /events is also read-capable at the application layer: POST /cypher (incl. workspace: "*") and GET /blobs/{session_id}. §4's path allowlist is the only thing keeping peers write-only — expose this server any other way and every issued peer token becomes a full read credential on the whole graph A require_write-only role for static/human principals (server change — authz.py)
Client sets the workspace field A peer can post events tagged with any workspace, including yours; no server-side validation Server-side workspace validation
workspace is not an authorization boundary Nothing binds a credential to a workspace. It is a label used for filtering, and any read-capable caller can pass workspace: "*" to cross all of them Per-credential workspace scoping enforced on /cypher and blob reads (server change)
No ingress rate/size cap A key holder can fill disk A reverse-proxy allowlist (e.g. Caddy) or server change

Per-peer keys are available and are what peer-onboarding.md assumes — issue each peer their own token via the api_keys keystore (managing-api-keys.md) so you can revoke or rotate one peer without re-onboarding the rest. That fixes revocation granularity; it does not change any row above, since every issued key carries identical capability.

For a handful of close collaborators these are usually acceptable. Make the call deliberately rather than by default.


See also