{"owner":"OpenNHP","repo":"opennhp","hasSkills":true,"totalSkillsCount":5,"totalTokensCount":17489,"categories":["root-instruction","claude-rule","mcp-config","marketplace","plugin-manifest"],"hasMcp":true,"mcpConfig":{"mcpServers":{"opennhp":{"command":"npx","args":["-y","@modelcontextprotocol/server-opennhp"]}}},"found":["AGENTS.md","CLAUDE.md",".cursor/mcp.json",".claude-plugin/marketplace.json",".claude-plugin/plugin.json"],"skills":{"AGENTS.md":"# AGENTS.md\n\nThis file provides guidance to Codex (Codex.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is the\nsingle source of truth `var.nhp_listen_port` in `terraform/demo/variables.tf`.\n`terraform/demo/security-groups.tf` derives both `aws_security_group.server`\nand `aws_security_group.ac` from it, and the `deploy-demo-v2` `configure` job\nreads it via `terraform output` and renders it into `server/config.toml`,\n`ac/server.toml`, and `relay/config.toml` via `${NHP_SERVER_PORT}`. To change\nthe port: edit `var.nhp_listen_port`, run `infra-demo apply` first (so the\nsecurity group rule is in place), then run `deploy-demo-v2` — the order\nmatters, because the rendered config tracks the applied state, never the\nsource-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit does not rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set and break the `Verify the XDP loader gave its capabilities\nback` gate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`).\nInstead, the drop-in adds\n`ExecStartPre=+/sbin/sysctl -q -w net.ipv4.ip_unprivileged_port_start=443`\nand writes `/etc/sysctl.d/99-nhp-server.conf` for persistence; the same line\nis in `terraform/demo/userdata/server.sh` for new hosts, and the two must\nstay in sync (see the `ignore_changes = [user_data]` note in `CLAUDE.md`).\nThe XDP filter allows only the knock port and whitelisted SSH, so widening\nthe unprivileged-port range to 443 does not broaden the host's exposure\nbeyond what the filter already permits.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe\n> to delete from `opennhp/demo` if desired (does not affect any\n> running cluster 1 host); the deploy script tolerates the field being\n> absent.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same\n`logs/nhp_server_xdp-YYYY-MM-DD.log` files the daemon writes. The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to the `stats_collector_ssh_public_key` entry in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads, not\n  `~/nhp-server/.ssh/...`). It accepts only `list` and `get <YYYY-MM-DD>`;\n  anything else (shell, scp, sftp, paths with `/`, malformed dates) is\n  rejected at the script. The `#` marker goes on its own line above the entry\n  — sshd treats a line *starting* with `#` as a comment.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host keys are verified against a known_hosts\n  seeded from `ssh_host_keys`; never TOFU.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*`.\n\n**Three directories, three permission stories.** The web root is\n`/var/www/stats` (0755), not under `$HOME`: `/home/ec2-user` is 0700 on\nAL2023 and nginx runs as `nginx`. `raw/` stays 0700 under `$HOME` — it is\nthe only place full, unmasked source IPs exist on the relay. `state.json` is\nwritten outside the web root via `-state`, and the vhost refuses\n`/state.json` as a second line of defence.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: untouched. Relay addresses were already in the SSH whitelist\n  before the stats site existed.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to `authorized_keys`\n  BEFORE `deploy-xdpstats` runs.\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nonly reads Terraform state, so the release that introduces the site has to be\n`infra-demo action=apply` → `deploy-demo-v2 confirm=deploy`.\n`deploy-xdpstats`'s first step requires `stats.opennhp.org` to resolve to\nexactly one A record equal to `aws_eip.relay.public_ip`, so a forgotten apply\nfails before the relay is touched. DNS-01 cert issuance would not catch it:\nit writes a TXT record through the Cloudflare API and never looks up the A\nrecord.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds; it is not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes,\n  not as zero attacks. RUNBOOK.md § \"XDP attack stats site\" has the full\n  recovery flow including key rotation via `opennhp/demo` and a forced rebuild\n  via `xdplog-report site -force`.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is\n`var.nhp_listen_port` in `terraform/demo/variables.tf` — that variable is the\nsingle source of truth. `terraform/demo/security-groups.tf` derives both\n`aws_security_group.server` and `aws_security_group.ac` from it, and the\n`deploy-demo-v2` `configure` job reads it via `terraform output` and renders it\ninto `server/config.toml`, `ac/server.toml`, and `relay/config.toml` via\n`${NHP_SERVER_PORT}`. To change the port: edit `var.nhp_listen_port`, run\n`infra-demo apply` first (so the security group rule is in place), then run\n`deploy-demo-v2` — the order matters, because the rendered config tracks the\napplied state, never the source-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. `infra-demo` also SSHes to the existing server through the relay before an `apply` with `open_all_ports` and refuses unless an XDP program is attached (`Require an attached XDP filter before opening all ports`); it is a point-in-time check, so the windows above remain accepted exposure. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit cannot rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set, breaking the `Verify the XDP loader gave its capabilities back`\ngate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`). Instead,\n`deploy-server` adds an `ExecStartPre=+/sbin/sysctl -q -w\nnet.ipv4.ip_unprivileged_port_start=443` to the drop-in, and writes\n`/etc/sysctl.d/99-nhp-server.conf` for persistence. The same line is in\n`terraform/demo/userdata/server.sh` so new hosts start the same way; the two\nmust stay in sync (see the note above about `ignore_changes = [user_data]`).\nRelaxing that floor widens the range any unprivileged process on the host\nmay bind to, but the XDP filter is what limits the host's inbound exposure\nnow that `aws_security_group.server` is deliberately open to all ports (see\n*All-ports exposure* above; `aws_security_group.ac` is still narrow), together\nwith the fact that this host runs a single service. The filter is fail-open by\ndesign, which is why the attach gate in `deploy-server` matters. A rollback that moves the port out of the\nprivileged range must therefore remove `/etc/sysctl.d/99-nhp-server.conf`\nand restore the kernel default (1024); the deploy job does this\nautomatically when `NHP_LISTEN_PORT >= 1024`.\n\n#### Deploy binaries are built in an Amazon Linux 2023 container\n\nThe three demo hosts run the AMI selected by `terraform/demo/ami.tf`\n(`al2023-ami-2023.*-x86_64`), whose glibc is **2.34**. `deploy-demo-v2` builds\n`nhp-serverd` / `nhp-acd` / `nhp-relayd` and the server plugins inside\n`docker/Dockerfile.al2023-builder` — an `amazonlinux:2023` image carrying the\n`GO_VERSION` toolchain — not on the runner, because the daemons are **cgo**\nbuilds and the glibc they link against is a floor on the glibc they can run on.\n\nBoth halves of that are load-bearing:\n\n- **cgo cannot be dropped**, at least for `nhp-serverd`. It loads its auth\n  plugins with `plugin.Open` (`nhp/plugins/serverpluginhandler.go`), and Go's\n  `plugin` package is a stub without cgo: the daemon starts clean and then\n  fails every plugin load with `plugin: not implemented`, which is the loss of\n  authentication, not of a feature. Host and `.so` are built in one container\n  for the same reason `scripts/check-plugin-deps.sh` exists.\n- **The builder cannot be newer than the target.** When `ubuntu-latest` moved\n  past glibc 2.34 the deploy stopped the running daemons, uploaded binaries\n  that exec'd into ``/lib64/libc.so.6: version `GLIBC_2.38' not found``, and\n  left nhp-serverd and nhp-acd down — the AC's fail-closed backstop holding\n  tcp/443 shut — on a run whose only symptom was a red `systemctl is-active`.\n\n`scripts/check-glibc-compat.sh` re-checks every ELF the job ships (the three\ndaemons, every plugin `.so`, the `demoapp` binary from `Dockerfile.demoapp`)\nagainst `TARGET_GLIBC` in the workflow env, and the same step asserts\n`nhp-serverd` is still dynamically linked against libc so that \"make the glibc\ncheck pass\" cannot be answered with `CGO_ENABLED=0`. It runs before the upload,\nso a mismatch fails `build` and no deploy job stops anything. The eBPF objects\nare BPF bytecode with no libc in them and are still compiled on the runner.\nRaise `TARGET_GLIBC` only when the demo AMI itself moves.\n\n#### nhp-server eBPF/XDP ingress filter\n\n`nhp-serverd` attaches `nhp/ebpf/xdp/nhp_server_xdp.c` at startup and enforces\nthe same shape at the driver: UDP on the knock port (≥ `NhpMinFrameBytes`), SSH\nonly from the addresses in `etc/xdp.toml`, the replies to flows the host itself\nopened, and nothing else — IPv6 and non-first fragments included. It parses no\nNHP protocol; identity is still decided by the Noise handshake in user space.\nThe AC's per-knock program and the shared loader\n(`nhp/utils/ebpf/engine_linux.go`) are the same code path, selected by\n`EngineLoadParams.Variant`.\n\nInvariants. Each one is load-bearing and most were paid for once already; the\nreasoning, and the incident behind each, is in\n[terraform/demo/RUNBOOK.md](terraform/demo/RUNBOOK.md) under *nhp-server ingress\nfilter (XDP)*.\n\n- **Attaching is opt-in; `etc/xdp.toml` is the opt-in.** No file, an unparsable\n  one, `Enabled = false`, or a `RelayIPs` whose entries do not all parse as IPv4\n  addresses or prefixes ⇒ nothing is attached and the host keeps the exposure it\n  had before the filter existed. `loadXdpConfig`/`startXdpFilter` in\n  `endpoints/server/config.go` also refuse in front of a non-loopback HTTP or\n  metrics listener, and `updateHttpConfig` refuses the mirror case — starting\n  such a listener while the filter is attached.\n- **The whitelist must cover the SSH source, and there is no break-glass path.**\n  A list rendered empty, or without an entry containing the address the host\n  sees SSH arrive from, means detaching the root volume or rebuilding the\n  instance. Entries are host addresses or CIDR prefixes in an LPM trie; one\n  unparsable entry fails the whole list rather than being skipped.\n- **Refusal reasons stay limited to the ones that protect SSH reachability.**\n  With `server_open_all_ports = true` the filter is the only inbound control, so\n  every new reason to refuse attaching is a way to leave all ports open. Telemetry\n  settings (`ScanReportIntervalSeconds`, `ScanReportTopN`) never decide this: an\n  out-of-range value is logged and disables only the `[NHP-SCAN]` summary\n  (`resolveScanReport`), and the filter attaches anyway.\n- **The whitelist is installed before the program is attached**, as\n  `EngineLoadParams.RelayIPs`, never written afterwards. Startup therefore never\n  calls `applyXdpConfig`; that path is the config watcher's, and it always has a\n  live filter and a working list to fall back on.\n- **A reload never weakens a working whitelist.** `applyXdpConfig` keeps the\n  active list on any refusal, and `ReplaceRelayIPs` is all-or-nothing against\n  the map as well as the file: it adds before it sweeps, rolls back its own adds\n  on a failed `Update`, and refuses a map it cannot read.\n- **Loading is fail-open**, and `deploy-server`'s `Verify the XDP ingress filter\n  attached` is what stops that from being a silent loss of protection. Every\n  fallible step happens before `link.AttachXDP`, and any error after it detaches.\n- **The filter must let the host's own traffic back in.** Replies to the\n  daemon's own flows via `bpf_sk_lookup_{tcp,udp}` (connected sockets only,\n  never listening ones); DHCP (67→68) and NTP by port pair, because their\n  clients have no connected socket. Dropping the DHCP answer costs the host its\n  address and every port with it, about an hour after a deploy that verified\n  clean.\n- **The capabilities go back on every path, including the ones that never\n  load.** `dropLoaderPrivileges` (`nhp/utils/ebpf/caps_linux.go`) subtracts\n  `CAP_BPF`, `CAP_NET_ADMIN` and `CAP_PERFMON` from ambient, permitted,\n  effective and inheritable — keeping CAP_BPF only where\n  `kernel.unprivileged_bpf_disabled` makes bpf(2) privileged and the reload path\n  still needs to write the map — process-wide, not just on the calling thread.\n  A subtraction, never an assignment of a fixed set: on the demo unit those\n  three *are* the whole grant, so what is left is nothing, but nhp-serverd also\n  runs as root in `docker/Dockerfile.server`, where resetting to the empty set\n  would silently and irreversibly take away CAP_DAC_OVERRIDE (volume-mounted\n  config owned by another uid) and CAP_NET_BIND_SERVICE (an `http.toml` reload\n  rebinding 80/443) on a host that never opted into filtering at all.\n  The unit grants all three as *ambient* capabilities before any of this code\n  runs, so a host that declines to filter is holding them too: `loadXdpConfig`\n  therefore defers `DropLoaderPrivileges` across all of its returns, not just\n  the loader's. A load that *failed*, and every refusal that never reached the\n  loader, keeps nothing, CAP_BPF included — there is no map to reload, and that\n  is the state the daemon stays in for the life of the process. The drop is\n  once-guarded and the loader goes first, so the attach path's answer is the one\n  that decides CAP_BPF.\n- **The event log is bounded**, because every line in it is a packet someone\n  else chose to send: per-class and global token buckets in the C, bulk classes\n  counted but never written per packet, a daily byte budget in the writer, exact\n  `[NHP-STAT]` totals regardless, and 14-day / 256 MiB retention.\n- **Recovery starts with a reboot** (`aws ec2 reboot-instances`, no SSH needed):\n  DHCP runs before `nhp-serverd`, so tcp/22 is open for the window before the\n  filter is re-attached. Volume surgery is only for a wrong *whitelist* on a\n  host that does have its address.\n\nThe gates run before anything is scp'd or restarted — the `configure` job's\nTerraform/DNS resolution and containment check, `deploy-server`'s\n`Check this job's own SSH source is in the XDP whitelist`, and the refusals in\n`startXdpFilter`, `applyXdpConfig` and `ParseRelayPrefixes`\n(`nhp/utils/ebpf/relayips.go`). A red run with the demo still up is the intended\noutcome of any of them.\n\nCapabilities (`CAP_BPF CAP_NET_ADMIN CAP_PERFMON`) plus a root `ExecStartPre`\nthat prepares `/sys/fs/bpf` come from `terraform/demo/userdata/server.sh` on new\nhosts and from a systemd drop-in installed by `deploy-server` on existing\nones — keep the two in sync. `aws_instance.server` carries\n`lifecycle { ignore_changes = [user_data] }` for the same reason\n`aws_instance.ac` does: an in-place `user_data` update stops and starts the\ninstance, and cloud-init would not re-run the script anyway, so a userdata edit\nwould cost an nhp-server outage and change nothing on the host.\n\nVerification procedure (netns rehearsal, pre-deploy baseline, the same commands\nafter the deploy, recovery paths):\n`terraform/demo/VERIFY-server-xdp.zh-cn.md`.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same `logs/nhp_server_xdp-YYYY-MM-DD.log`\nfiles the daemon writes (and the daemon's per-packet retention is the only\nwindow: 14 days, see *nhp-server eBPF/XDP ingress filter* above). The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log to\n  `/home/ec2-user/nhp-server/logs/nhp_server_xdp-YYYY-MM-DD.log` as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to a dedicated ed25519 public key in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads — not\n  `~/nhp-server/.ssh/...`, which nothing looks at). It accepts only `list`\n  (print date/size/mtime) and `get <YYYY-MM-DD>` (stream one log to stdout);\n  anything else (shell, scp, sftp, paths with `/`, dates that don't match\n  `^\\d{4}-\\d{2}-\\d{2}$`) is rejected at the script. The matching private key\n  is `stats_collector_ssh_private_key` in `opennp/demo`; the public half is\n  installed by `deploy-server` with\n  `command=\".../nhp-xdplog-export.sh\",no-port-forwarding,no-X11-forwarding,\n  no-agent-forwarding,no-pty`. The descriptive `#` marker sits on its OWN\n  line above the entry, because sshd reads any line *starting* with `#` as a\n  comment and would otherwise never authorize the key.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host-key checking is\n  `StrictHostKeyChecking=yes` against a known_hosts seeded from\n  `ssh_host_keys` by `deploy-xdpstats` — never TOFU, because the collector is\n  a timer with nobody to answer a prompt.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*` (SAN expanded by\n  `bootstrap-tls.sh`).\n\n**Three directories, three permission stories.** `/home/ec2-user` is mode\n0700 on AL2023 and nginx workers run as `nginx`, so the web root is\n`/var/www/stats` (0755) and NOT under `$HOME`; `raw/` stays 0700 under\n`$HOME` because it is the only place full, unmasked source IPs exist on the\nrelay; and `state.json` is written outside the web root via\n`xdplog-report site -state`, because it records the absolute path of every\nraw cache file. `deploy/nginx/stats.conf.template` also refuses\n`location = /state.json` as a second line of defence, and repeats the\nsecurity headers inside every `location` — nginx only inherits `add_header`\nfrom an enclosing block when the inner block declares none of its own.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: not touched. The relay's addresses were already in the SSH\n  whitelist when `server_open_all_ports = false` was the default; opening all\n  ports added an internet-wide observation surface but did not relax that\n  list. Collector SSH rides in on the same path the deploy job uses.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to\n  `authorized_keys` BEFORE `deploy-xdpstats` runs (first collector SSH would\n  otherwise hit an unknown public-key fallback).\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nnever applies Terraform (`configure` only reads the state) — so the release\nthat introduces the site has to be `infra-demo action=apply` →\n`deploy-demo-v2 confirm=deploy`. `deploy-xdpstats`'s first step is a preflight\nthat requires `stats.opennhp.org` to resolve to exactly one A record equal to\n`aws_eip.relay.public_ip`, so a forgotten apply is a red run before the relay\nis touched rather than a red curl after the vhost, collector and timer are\ninstalled. DNS-01 cert issuance cannot be relied on to catch it: it writes a\nTXT record through the Cloudflare API and never looks up the A record.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds and post-classifier retroanalysis; it is\n  not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes, not\n  as zero attacks (a missing minute is not the same as \"no attack in that\n  minute\").\n\n**Adding the AC later.** AC writes per-flow accept/deny files in a shape\n`parse.go` does not currently parse. Adding it means (1) a parser extension\nin `endpoints/xdplog-report/parse.go`, (2) a new SSH keypair +\n`authorized_keys` line on the AC host, (3) a second `<name> <user> <host>\n<log_dir>` line in `sources.conf`. The site already routes by `<source>`,\nso no `xdplog-report` change is needed once the parser and keypair exist.\n\n**Manual backfill.** If `xdpstats.timer` missed a window longer than the\ndaemon's 14-day retention, the missing days are unrecoverable; the next\ndeploy should add a one-shot run of `xdplogs-collect.sh -v` after the gap is\nknown. RUNBOOK.md § \"XDP attack stats site\" has the full recovery flow\nincluding key rotation via `opennhp/demo` and a forced rebuild via\n`xdplog-report site -force`.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe to\n> delete from `opennhp/demo` if desired (does not affect any running\n> cluster 1 host); the deploy script tolerates the field being absent.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n",".cursor/mcp.json":"# Model Context Protocol (MCP) Configuration\n\nPath: `.cursor/mcp.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.cursor/mcp.json)",".claude-plugin/marketplace.json":"# Claude Plugin Marketplace Catalog\n\nPath: `.claude-plugin/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/marketplace.json)",".claude-plugin/plugin.json":"# claude-plugin Plugin Manifest\n\nPath: `.claude-plugin/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/plugin.json)"},"files":{"AGENTS.md":"# AGENTS.md\n\nThis file provides guidance to Codex (Codex.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is the\nsingle source of truth `var.nhp_listen_port` in `terraform/demo/variables.tf`.\n`terraform/demo/security-groups.tf` derives both `aws_security_group.server`\nand `aws_security_group.ac` from it, and the `deploy-demo-v2` `configure` job\nreads it via `terraform output` and renders it into `server/config.toml`,\n`ac/server.toml`, and `relay/config.toml` via `${NHP_SERVER_PORT}`. To change\nthe port: edit `var.nhp_listen_port`, run `infra-demo apply` first (so the\nsecurity group rule is in place), then run `deploy-demo-v2` — the order\nmatters, because the rendered config tracks the applied state, never the\nsource-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit does not rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set and break the `Verify the XDP loader gave its capabilities\nback` gate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`).\nInstead, the drop-in adds\n`ExecStartPre=+/sbin/sysctl -q -w net.ipv4.ip_unprivileged_port_start=443`\nand writes `/etc/sysctl.d/99-nhp-server.conf` for persistence; the same line\nis in `terraform/demo/userdata/server.sh` for new hosts, and the two must\nstay in sync (see the `ignore_changes = [user_data]` note in `CLAUDE.md`).\nThe XDP filter allows only the knock port and whitelisted SSH, so widening\nthe unprivileged-port range to 443 does not broaden the host's exposure\nbeyond what the filter already permits.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe\n> to delete from `opennhp/demo` if desired (does not affect any\n> running cluster 1 host); the deploy script tolerates the field being\n> absent.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same\n`logs/nhp_server_xdp-YYYY-MM-DD.log` files the daemon writes. The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to the `stats_collector_ssh_public_key` entry in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads, not\n  `~/nhp-server/.ssh/...`). It accepts only `list` and `get <YYYY-MM-DD>`;\n  anything else (shell, scp, sftp, paths with `/`, malformed dates) is\n  rejected at the script. The `#` marker goes on its own line above the entry\n  — sshd treats a line *starting* with `#` as a comment.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host keys are verified against a known_hosts\n  seeded from `ssh_host_keys`; never TOFU.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*`.\n\n**Three directories, three permission stories.** The web root is\n`/var/www/stats` (0755), not under `$HOME`: `/home/ec2-user` is 0700 on\nAL2023 and nginx runs as `nginx`. `raw/` stays 0700 under `$HOME` — it is\nthe only place full, unmasked source IPs exist on the relay. `state.json` is\nwritten outside the web root via `-state`, and the vhost refuses\n`/state.json` as a second line of defence.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: untouched. Relay addresses were already in the SSH whitelist\n  before the stats site existed.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to `authorized_keys`\n  BEFORE `deploy-xdpstats` runs.\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nonly reads Terraform state, so the release that introduces the site has to be\n`infra-demo action=apply` → `deploy-demo-v2 confirm=deploy`.\n`deploy-xdpstats`'s first step requires `stats.opennhp.org` to resolve to\nexactly one A record equal to `aws_eip.relay.public_ip`, so a forgotten apply\nfails before the relay is touched. DNS-01 cert issuance would not catch it:\nit writes a TXT record through the Cloudflare API and never looks up the A\nrecord.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds; it is not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes,\n  not as zero attacks. RUNBOOK.md § \"XDP attack stats site\" has the full\n  recovery flow including key rotation via `opennhp/demo` and a forced rebuild\n  via `xdplog-report site -force`.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is\n`var.nhp_listen_port` in `terraform/demo/variables.tf` — that variable is the\nsingle source of truth. `terraform/demo/security-groups.tf` derives both\n`aws_security_group.server` and `aws_security_group.ac` from it, and the\n`deploy-demo-v2` `configure` job reads it via `terraform output` and renders it\ninto `server/config.toml`, `ac/server.toml`, and `relay/config.toml` via\n`${NHP_SERVER_PORT}`. To change the port: edit `var.nhp_listen_port`, run\n`infra-demo apply` first (so the security group rule is in place), then run\n`deploy-demo-v2` — the order matters, because the rendered config tracks the\napplied state, never the source-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. `infra-demo` also SSHes to the existing server through the relay before an `apply` with `open_all_ports` and refuses unless an XDP program is attached (`Require an attached XDP filter before opening all ports`); it is a point-in-time check, so the windows above remain accepted exposure. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit cannot rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set, breaking the `Verify the XDP loader gave its capabilities back`\ngate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`). Instead,\n`deploy-server` adds an `ExecStartPre=+/sbin/sysctl -q -w\nnet.ipv4.ip_unprivileged_port_start=443` to the drop-in, and writes\n`/etc/sysctl.d/99-nhp-server.conf` for persistence. The same line is in\n`terraform/demo/userdata/server.sh` so new hosts start the same way; the two\nmust stay in sync (see the note above about `ignore_changes = [user_data]`).\nRelaxing that floor widens the range any unprivileged process on the host\nmay bind to, but the XDP filter is what limits the host's inbound exposure\nnow that `aws_security_group.server` is deliberately open to all ports (see\n*All-ports exposure* above; `aws_security_group.ac` is still narrow), together\nwith the fact that this host runs a single service. The filter is fail-open by\ndesign, which is why the attach gate in `deploy-server` matters. A rollback that moves the port out of the\nprivileged range must therefore remove `/etc/sysctl.d/99-nhp-server.conf`\nand restore the kernel default (1024); the deploy job does this\nautomatically when `NHP_LISTEN_PORT >= 1024`.\n\n#### Deploy binaries are built in an Amazon Linux 2023 container\n\nThe three demo hosts run the AMI selected by `terraform/demo/ami.tf`\n(`al2023-ami-2023.*-x86_64`), whose glibc is **2.34**. `deploy-demo-v2` builds\n`nhp-serverd` / `nhp-acd` / `nhp-relayd` and the server plugins inside\n`docker/Dockerfile.al2023-builder` — an `amazonlinux:2023` image carrying the\n`GO_VERSION` toolchain — not on the runner, because the daemons are **cgo**\nbuilds and the glibc they link against is a floor on the glibc they can run on.\n\nBoth halves of that are load-bearing:\n\n- **cgo cannot be dropped**, at least for `nhp-serverd`. It loads its auth\n  plugins with `plugin.Open` (`nhp/plugins/serverpluginhandler.go`), and Go's\n  `plugin` package is a stub without cgo: the daemon starts clean and then\n  fails every plugin load with `plugin: not implemented`, which is the loss of\n  authentication, not of a feature. Host and `.so` are built in one container\n  for the same reason `scripts/check-plugin-deps.sh` exists.\n- **The builder cannot be newer than the target.** When `ubuntu-latest` moved\n  past glibc 2.34 the deploy stopped the running daemons, uploaded binaries\n  that exec'd into ``/lib64/libc.so.6: version `GLIBC_2.38' not found``, and\n  left nhp-serverd and nhp-acd down — the AC's fail-closed backstop holding\n  tcp/443 shut — on a run whose only symptom was a red `systemctl is-active`.\n\n`scripts/check-glibc-compat.sh` re-checks every ELF the job ships (the three\ndaemons, every plugin `.so`, the `demoapp` binary from `Dockerfile.demoapp`)\nagainst `TARGET_GLIBC` in the workflow env, and the same step asserts\n`nhp-serverd` is still dynamically linked against libc so that \"make the glibc\ncheck pass\" cannot be answered with `CGO_ENABLED=0`. It runs before the upload,\nso a mismatch fails `build` and no deploy job stops anything. The eBPF objects\nare BPF bytecode with no libc in them and are still compiled on the runner.\nRaise `TARGET_GLIBC` only when the demo AMI itself moves.\n\n#### nhp-server eBPF/XDP ingress filter\n\n`nhp-serverd` attaches `nhp/ebpf/xdp/nhp_server_xdp.c` at startup and enforces\nthe same shape at the driver: UDP on the knock port (≥ `NhpMinFrameBytes`), SSH\nonly from the addresses in `etc/xdp.toml`, the replies to flows the host itself\nopened, and nothing else — IPv6 and non-first fragments included. It parses no\nNHP protocol; identity is still decided by the Noise handshake in user space.\nThe AC's per-knock program and the shared loader\n(`nhp/utils/ebpf/engine_linux.go`) are the same code path, selected by\n`EngineLoadParams.Variant`.\n\nInvariants. Each one is load-bearing and most were paid for once already; the\nreasoning, and the incident behind each, is in\n[terraform/demo/RUNBOOK.md](terraform/demo/RUNBOOK.md) under *nhp-server ingress\nfilter (XDP)*.\n\n- **Attaching is opt-in; `etc/xdp.toml` is the opt-in.** No file, an unparsable\n  one, `Enabled = false`, or a `RelayIPs` whose entries do not all parse as IPv4\n  addresses or prefixes ⇒ nothing is attached and the host keeps the exposure it\n  had before the filter existed. `loadXdpConfig`/`startXdpFilter` in\n  `endpoints/server/config.go` also refuse in front of a non-loopback HTTP or\n  metrics listener, and `updateHttpConfig` refuses the mirror case — starting\n  such a listener while the filter is attached.\n- **The whitelist must cover the SSH source, and there is no break-glass path.**\n  A list rendered empty, or without an entry containing the address the host\n  sees SSH arrive from, means detaching the root volume or rebuilding the\n  instance. Entries are host addresses or CIDR prefixes in an LPM trie; one\n  unparsable entry fails the whole list rather than being skipped.\n- **Refusal reasons stay limited to the ones that protect SSH reachability.**\n  With `server_open_all_ports = true` the filter is the only inbound control, so\n  every new reason to refuse attaching is a way to leave all ports open. Telemetry\n  settings (`ScanReportIntervalSeconds`, `ScanReportTopN`) never decide this: an\n  out-of-range value is logged and disables only the `[NHP-SCAN]` summary\n  (`resolveScanReport`), and the filter attaches anyway.\n- **The whitelist is installed before the program is attached**, as\n  `EngineLoadParams.RelayIPs`, never written afterwards. Startup therefore never\n  calls `applyXdpConfig`; that path is the config watcher's, and it always has a\n  live filter and a working list to fall back on.\n- **A reload never weakens a working whitelist.** `applyXdpConfig` keeps the\n  active list on any refusal, and `ReplaceRelayIPs` is all-or-nothing against\n  the map as well as the file: it adds before it sweeps, rolls back its own adds\n  on a failed `Update`, and refuses a map it cannot read.\n- **Loading is fail-open**, and `deploy-server`'s `Verify the XDP ingress filter\n  attached` is what stops that from being a silent loss of protection. Every\n  fallible step happens before `link.AttachXDP`, and any error after it detaches.\n- **The filter must let the host's own traffic back in.** Replies to the\n  daemon's own flows via `bpf_sk_lookup_{tcp,udp}` (connected sockets only,\n  never listening ones); DHCP (67→68) and NTP by port pair, because their\n  clients have no connected socket. Dropping the DHCP answer costs the host its\n  address and every port with it, about an hour after a deploy that verified\n  clean.\n- **The capabilities go back on every path, including the ones that never\n  load.** `dropLoaderPrivileges` (`nhp/utils/ebpf/caps_linux.go`) subtracts\n  `CAP_BPF`, `CAP_NET_ADMIN` and `CAP_PERFMON` from ambient, permitted,\n  effective and inheritable — keeping CAP_BPF only where\n  `kernel.unprivileged_bpf_disabled` makes bpf(2) privileged and the reload path\n  still needs to write the map — process-wide, not just on the calling thread.\n  A subtraction, never an assignment of a fixed set: on the demo unit those\n  three *are* the whole grant, so what is left is nothing, but nhp-serverd also\n  runs as root in `docker/Dockerfile.server`, where resetting to the empty set\n  would silently and irreversibly take away CAP_DAC_OVERRIDE (volume-mounted\n  config owned by another uid) and CAP_NET_BIND_SERVICE (an `http.toml` reload\n  rebinding 80/443) on a host that never opted into filtering at all.\n  The unit grants all three as *ambient* capabilities before any of this code\n  runs, so a host that declines to filter is holding them too: `loadXdpConfig`\n  therefore defers `DropLoaderPrivileges` across all of its returns, not just\n  the loader's. A load that *failed*, and every refusal that never reached the\n  loader, keeps nothing, CAP_BPF included — there is no map to reload, and that\n  is the state the daemon stays in for the life of the process. The drop is\n  once-guarded and the loader goes first, so the attach path's answer is the one\n  that decides CAP_BPF.\n- **The event log is bounded**, because every line in it is a packet someone\n  else chose to send: per-class and global token buckets in the C, bulk classes\n  counted but never written per packet, a daily byte budget in the writer, exact\n  `[NHP-STAT]` totals regardless, and 14-day / 256 MiB retention.\n- **Recovery starts with a reboot** (`aws ec2 reboot-instances`, no SSH needed):\n  DHCP runs before `nhp-serverd`, so tcp/22 is open for the window before the\n  filter is re-attached. Volume surgery is only for a wrong *whitelist* on a\n  host that does have its address.\n\nThe gates run before anything is scp'd or restarted — the `configure` job's\nTerraform/DNS resolution and containment check, `deploy-server`'s\n`Check this job's own SSH source is in the XDP whitelist`, and the refusals in\n`startXdpFilter`, `applyXdpConfig` and `ParseRelayPrefixes`\n(`nhp/utils/ebpf/relayips.go`). A red run with the demo still up is the intended\noutcome of any of them.\n\nCapabilities (`CAP_BPF CAP_NET_ADMIN CAP_PERFMON`) plus a root `ExecStartPre`\nthat prepares `/sys/fs/bpf` come from `terraform/demo/userdata/server.sh` on new\nhosts and from a systemd drop-in installed by `deploy-server` on existing\nones — keep the two in sync. `aws_instance.server` carries\n`lifecycle { ignore_changes = [user_data] }` for the same reason\n`aws_instance.ac` does: an in-place `user_data` update stops and starts the\ninstance, and cloud-init would not re-run the script anyway, so a userdata edit\nwould cost an nhp-server outage and change nothing on the host.\n\nVerification procedure (netns rehearsal, pre-deploy baseline, the same commands\nafter the deploy, recovery paths):\n`terraform/demo/VERIFY-server-xdp.zh-cn.md`.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same `logs/nhp_server_xdp-YYYY-MM-DD.log`\nfiles the daemon writes (and the daemon's per-packet retention is the only\nwindow: 14 days, see *nhp-server eBPF/XDP ingress filter* above). The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log to\n  `/home/ec2-user/nhp-server/logs/nhp_server_xdp-YYYY-MM-DD.log` as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to a dedicated ed25519 public key in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads — not\n  `~/nhp-server/.ssh/...`, which nothing looks at). It accepts only `list`\n  (print date/size/mtime) and `get <YYYY-MM-DD>` (stream one log to stdout);\n  anything else (shell, scp, sftp, paths with `/`, dates that don't match\n  `^\\d{4}-\\d{2}-\\d{2}$`) is rejected at the script. The matching private key\n  is `stats_collector_ssh_private_key` in `opennp/demo`; the public half is\n  installed by `deploy-server` with\n  `command=\".../nhp-xdplog-export.sh\",no-port-forwarding,no-X11-forwarding,\n  no-agent-forwarding,no-pty`. The descriptive `#` marker sits on its OWN\n  line above the entry, because sshd reads any line *starting* with `#` as a\n  comment and would otherwise never authorize the key.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host-key checking is\n  `StrictHostKeyChecking=yes` against a known_hosts seeded from\n  `ssh_host_keys` by `deploy-xdpstats` — never TOFU, because the collector is\n  a timer with nobody to answer a prompt.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*` (SAN expanded by\n  `bootstrap-tls.sh`).\n\n**Three directories, three permission stories.** `/home/ec2-user` is mode\n0700 on AL2023 and nginx workers run as `nginx`, so the web root is\n`/var/www/stats` (0755) and NOT under `$HOME`; `raw/` stays 0700 under\n`$HOME` because it is the only place full, unmasked source IPs exist on the\nrelay; and `state.json` is written outside the web root via\n`xdplog-report site -state`, because it records the absolute path of every\nraw cache file. `deploy/nginx/stats.conf.template` also refuses\n`location = /state.json` as a second line of defence, and repeats the\nsecurity headers inside every `location` — nginx only inherits `add_header`\nfrom an enclosing block when the inner block declares none of its own.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: not touched. The relay's addresses were already in the SSH\n  whitelist when `server_open_all_ports = false` was the default; opening all\n  ports added an internet-wide observation surface but did not relax that\n  list. Collector SSH rides in on the same path the deploy job uses.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to\n  `authorized_keys` BEFORE `deploy-xdpstats` runs (first collector SSH would\n  otherwise hit an unknown public-key fallback).\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nnever applies Terraform (`configure` only reads the state) — so the release\nthat introduces the site has to be `infra-demo action=apply` →\n`deploy-demo-v2 confirm=deploy`. `deploy-xdpstats`'s first step is a preflight\nthat requires `stats.opennhp.org` to resolve to exactly one A record equal to\n`aws_eip.relay.public_ip`, so a forgotten apply is a red run before the relay\nis touched rather than a red curl after the vhost, collector and timer are\ninstalled. DNS-01 cert issuance cannot be relied on to catch it: it writes a\nTXT record through the Cloudflare API and never looks up the A record.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds and post-classifier retroanalysis; it is\n  not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes, not\n  as zero attacks (a missing minute is not the same as \"no attack in that\n  minute\").\n\n**Adding the AC later.** AC writes per-flow accept/deny files in a shape\n`parse.go` does not currently parse. Adding it means (1) a parser extension\nin `endpoints/xdplog-report/parse.go`, (2) a new SSH keypair +\n`authorized_keys` line on the AC host, (3) a second `<name> <user> <host>\n<log_dir>` line in `sources.conf`. The site already routes by `<source>`,\nso no `xdplog-report` change is needed once the parser and keypair exist.\n\n**Manual backfill.** If `xdpstats.timer` missed a window longer than the\ndaemon's 14-day retention, the missing days are unrecoverable; the next\ndeploy should add a one-shot run of `xdplogs-collect.sh -v` after the gap is\nknown. RUNBOOK.md § \"XDP attack stats site\" has the full recovery flow\nincluding key rotation via `opennhp/demo` and a forced rebuild via\n`xdplog-report site -force`.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe to\n> delete from `opennhp/demo` if desired (does not affect any running\n> cluster 1 host); the deploy script tolerates the field being absent.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n",".cursor/mcp.json":"# Model Context Protocol (MCP) Configuration\n\nPath: `.cursor/mcp.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.cursor/mcp.json)",".claude-plugin/marketplace.json":"# Claude Plugin Marketplace Catalog\n\nPath: `.claude-plugin/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/marketplace.json)",".claude-plugin/plugin.json":"# claude-plugin Plugin Manifest\n\nPath: `.claude-plugin/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/plugin.json)"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\nThis file provides guidance to Codex (Codex.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is the\nsingle source of truth `var.nhp_listen_port` in `terraform/demo/variables.tf`.\n`terraform/demo/security-groups.tf` derives both `aws_security_group.server`\nand `aws_security_group.ac` from it, and the `deploy-demo-v2` `configure` job\nreads it via `terraform output` and renders it into `server/config.toml`,\n`ac/server.toml`, and `relay/config.toml` via `${NHP_SERVER_PORT}`. To change\nthe port: edit `var.nhp_listen_port`, run `infra-demo apply` first (so the\nsecurity group rule is in place), then run `deploy-demo-v2` — the order\nmatters, because the rendered config tracks the applied state, never the\nsource-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit does not rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set and break the `Verify the XDP loader gave its capabilities\nback` gate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`).\nInstead, the drop-in adds\n`ExecStartPre=+/sbin/sysctl -q -w net.ipv4.ip_unprivileged_port_start=443`\nand writes `/etc/sysctl.d/99-nhp-server.conf` for persistence; the same line\nis in `terraform/demo/userdata/server.sh` for new hosts, and the two must\nstay in sync (see the `ignore_changes = [user_data]` note in `CLAUDE.md`).\nThe XDP filter allows only the knock port and whitelisted SSH, so widening\nthe unprivileged-port range to 443 does not broaden the host's exposure\nbeyond what the filter already permits.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe\n> to delete from `opennhp/demo` if desired (does not affect any\n> running cluster 1 host); the deploy script tolerates the field being\n> absent.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same\n`logs/nhp_server_xdp-YYYY-MM-DD.log` files the daemon writes. The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to the `stats_collector_ssh_public_key` entry in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads, not\n  `~/nhp-server/.ssh/...`). It accepts only `list` and `get <YYYY-MM-DD>`;\n  anything else (shell, scp, sftp, paths with `/`, malformed dates) is\n  rejected at the script. The `#` marker goes on its own line above the entry\n  — sshd treats a line *starting* with `#` as a comment.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host keys are verified against a known_hosts\n  seeded from `ssh_host_keys`; never TOFU.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*`.\n\n**Three directories, three permission stories.** The web root is\n`/var/www/stats` (0755), not under `$HOME`: `/home/ec2-user` is 0700 on\nAL2023 and nginx runs as `nginx`. `raw/` stays 0700 under `$HOME` — it is\nthe only place full, unmasked source IPs exist on the relay. `state.json` is\nwritten outside the web root via `-state`, and the vhost refuses\n`/state.json` as a second line of defence.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: untouched. Relay addresses were already in the SSH whitelist\n  before the stats site existed.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to `authorized_keys`\n  BEFORE `deploy-xdpstats` runs.\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nonly reads Terraform state, so the release that introduces the site has to be\n`infra-demo action=apply` → `deploy-demo-v2 confirm=deploy`.\n`deploy-xdpstats`'s first step requires `stats.opennhp.org` to resolve to\nexactly one A record equal to `aws_eip.relay.public_ip`, so a forgotten apply\nfails before the relay is touched. DNS-01 cert issuance would not catch it:\nit writes a TXT record through the Cloudflare API and never looks up the A\nrecord.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds; it is not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes,\n  not as zero attacks. RUNBOOK.md § \"XDP attack stats site\" has the full\n  recovery flow including key rotation via `opennhp/demo` and a forced rebuild\n  via `xdplog-report site -force`.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n","isInternal":false,"tokens":6802,"sizeBytes":26156},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is\n`var.nhp_listen_port` in `terraform/demo/variables.tf` — that variable is the\nsingle source of truth. `terraform/demo/security-groups.tf` derives both\n`aws_security_group.server` and `aws_security_group.ac` from it, and the\n`deploy-demo-v2` `configure` job reads it via `terraform output` and renders it\ninto `server/config.toml`, `ac/server.toml`, and `relay/config.toml` via\n`${NHP_SERVER_PORT}`. To change the port: edit `var.nhp_listen_port`, run\n`infra-demo apply` first (so the security group rule is in place), then run\n`deploy-demo-v2` — the order matters, because the rendered config tracks the\napplied state, never the source-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. `infra-demo` also SSHes to the existing server through the relay before an `apply` with `open_all_ports` and refuses unless an XDP program is attached (`Require an attached XDP filter before opening all ports`); it is a point-in-time check, so the windows above remain accepted exposure. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit cannot rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set, breaking the `Verify the XDP loader gave its capabilities back`\ngate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`). Instead,\n`deploy-server` adds an `ExecStartPre=+/sbin/sysctl -q -w\nnet.ipv4.ip_unprivileged_port_start=443` to the drop-in, and writes\n`/etc/sysctl.d/99-nhp-server.conf` for persistence. The same line is in\n`terraform/demo/userdata/server.sh` so new hosts start the same way; the two\nmust stay in sync (see the note above about `ignore_changes = [user_data]`).\nRelaxing that floor widens the range any unprivileged process on the host\nmay bind to, but the XDP filter is what limits the host's inbound exposure\nnow that `aws_security_group.server` is deliberately open to all ports (see\n*All-ports exposure* above; `aws_security_group.ac` is still narrow), together\nwith the fact that this host runs a single service. The filter is fail-open by\ndesign, which is why the attach gate in `deploy-server` matters. A rollback that moves the port out of the\nprivileged range must therefore remove `/etc/sysctl.d/99-nhp-server.conf`\nand restore the kernel default (1024); the deploy job does this\nautomatically when `NHP_LISTEN_PORT >= 1024`.\n\n#### Deploy binaries are built in an Amazon Linux 2023 container\n\nThe three demo hosts run the AMI selected by `terraform/demo/ami.tf`\n(`al2023-ami-2023.*-x86_64`), whose glibc is **2.34**. `deploy-demo-v2` builds\n`nhp-serverd` / `nhp-acd` / `nhp-relayd` and the server plugins inside\n`docker/Dockerfile.al2023-builder` — an `amazonlinux:2023` image carrying the\n`GO_VERSION` toolchain — not on the runner, because the daemons are **cgo**\nbuilds and the glibc they link against is a floor on the glibc they can run on.\n\nBoth halves of that are load-bearing:\n\n- **cgo cannot be dropped**, at least for `nhp-serverd`. It loads its auth\n  plugins with `plugin.Open` (`nhp/plugins/serverpluginhandler.go`), and Go's\n  `plugin` package is a stub without cgo: the daemon starts clean and then\n  fails every plugin load with `plugin: not implemented`, which is the loss of\n  authentication, not of a feature. Host and `.so` are built in one container\n  for the same reason `scripts/check-plugin-deps.sh` exists.\n- **The builder cannot be newer than the target.** When `ubuntu-latest` moved\n  past glibc 2.34 the deploy stopped the running daemons, uploaded binaries\n  that exec'd into ``/lib64/libc.so.6: version `GLIBC_2.38' not found``, and\n  left nhp-serverd and nhp-acd down — the AC's fail-closed backstop holding\n  tcp/443 shut — on a run whose only symptom was a red `systemctl is-active`.\n\n`scripts/check-glibc-compat.sh` re-checks every ELF the job ships (the three\ndaemons, every plugin `.so`, the `demoapp` binary from `Dockerfile.demoapp`)\nagainst `TARGET_GLIBC` in the workflow env, and the same step asserts\n`nhp-serverd` is still dynamically linked against libc so that \"make the glibc\ncheck pass\" cannot be answered with `CGO_ENABLED=0`. It runs before the upload,\nso a mismatch fails `build` and no deploy job stops anything. The eBPF objects\nare BPF bytecode with no libc in them and are still compiled on the runner.\nRaise `TARGET_GLIBC` only when the demo AMI itself moves.\n\n#### nhp-server eBPF/XDP ingress filter\n\n`nhp-serverd` attaches `nhp/ebpf/xdp/nhp_server_xdp.c` at startup and enforces\nthe same shape at the driver: UDP on the knock port (≥ `NhpMinFrameBytes`), SSH\nonly from the addresses in `etc/xdp.toml`, the replies to flows the host itself\nopened, and nothing else — IPv6 and non-first fragments included. It parses no\nNHP protocol; identity is still decided by the Noise handshake in user space.\nThe AC's per-knock program and the shared loader\n(`nhp/utils/ebpf/engine_linux.go`) are the same code path, selected by\n`EngineLoadParams.Variant`.\n\nInvariants. Each one is load-bearing and most were paid for once already; the\nreasoning, and the incident behind each, is in\n[terraform/demo/RUNBOOK.md](terraform/demo/RUNBOOK.md) under *nhp-server ingress\nfilter (XDP)*.\n\n- **Attaching is opt-in; `etc/xdp.toml` is the opt-in.** No file, an unparsable\n  one, `Enabled = false`, or a `RelayIPs` whose entries do not all parse as IPv4\n  addresses or prefixes ⇒ nothing is attached and the host keeps the exposure it\n  had before the filter existed. `loadXdpConfig`/`startXdpFilter` in\n  `endpoints/server/config.go` also refuse in front of a non-loopback HTTP or\n  metrics listener, and `updateHttpConfig` refuses the mirror case — starting\n  such a listener while the filter is attached.\n- **The whitelist must cover the SSH source, and there is no break-glass path.**\n  A list rendered empty, or without an entry containing the address the host\n  sees SSH arrive from, means detaching the root volume or rebuilding the\n  instance. Entries are host addresses or CIDR prefixes in an LPM trie; one\n  unparsable entry fails the whole list rather than being skipped.\n- **Refusal reasons stay limited to the ones that protect SSH reachability.**\n  With `server_open_all_ports = true` the filter is the only inbound control, so\n  every new reason to refuse attaching is a way to leave all ports open. Telemetry\n  settings (`ScanReportIntervalSeconds`, `ScanReportTopN`) never decide this: an\n  out-of-range value is logged and disables only the `[NHP-SCAN]` summary\n  (`resolveScanReport`), and the filter attaches anyway.\n- **The whitelist is installed before the program is attached**, as\n  `EngineLoadParams.RelayIPs`, never written afterwards. Startup therefore never\n  calls `applyXdpConfig`; that path is the config watcher's, and it always has a\n  live filter and a working list to fall back on.\n- **A reload never weakens a working whitelist.** `applyXdpConfig` keeps the\n  active list on any refusal, and `ReplaceRelayIPs` is all-or-nothing against\n  the map as well as the file: it adds before it sweeps, rolls back its own adds\n  on a failed `Update`, and refuses a map it cannot read.\n- **Loading is fail-open**, and `deploy-server`'s `Verify the XDP ingress filter\n  attached` is what stops that from being a silent loss of protection. Every\n  fallible step happens before `link.AttachXDP`, and any error after it detaches.\n- **The filter must let the host's own traffic back in.** Replies to the\n  daemon's own flows via `bpf_sk_lookup_{tcp,udp}` (connected sockets only,\n  never listening ones); DHCP (67→68) and NTP by port pair, because their\n  clients have no connected socket. Dropping the DHCP answer costs the host its\n  address and every port with it, about an hour after a deploy that verified\n  clean.\n- **The capabilities go back on every path, including the ones that never\n  load.** `dropLoaderPrivileges` (`nhp/utils/ebpf/caps_linux.go`) subtracts\n  `CAP_BPF`, `CAP_NET_ADMIN` and `CAP_PERFMON` from ambient, permitted,\n  effective and inheritable — keeping CAP_BPF only where\n  `kernel.unprivileged_bpf_disabled` makes bpf(2) privileged and the reload path\n  still needs to write the map — process-wide, not just on the calling thread.\n  A subtraction, never an assignment of a fixed set: on the demo unit those\n  three *are* the whole grant, so what is left is nothing, but nhp-serverd also\n  runs as root in `docker/Dockerfile.server`, where resetting to the empty set\n  would silently and irreversibly take away CAP_DAC_OVERRIDE (volume-mounted\n  config owned by another uid) and CAP_NET_BIND_SERVICE (an `http.toml` reload\n  rebinding 80/443) on a host that never opted into filtering at all.\n  The unit grants all three as *ambient* capabilities before any of this code\n  runs, so a host that declines to filter is holding them too: `loadXdpConfig`\n  therefore defers `DropLoaderPrivileges` across all of its returns, not just\n  the loader's. A load that *failed*, and every refusal that never reached the\n  loader, keeps nothing, CAP_BPF included — there is no map to reload, and that\n  is the state the daemon stays in for the life of the process. The drop is\n  once-guarded and the loader goes first, so the attach path's answer is the one\n  that decides CAP_BPF.\n- **The event log is bounded**, because every line in it is a packet someone\n  else chose to send: per-class and global token buckets in the C, bulk classes\n  counted but never written per packet, a daily byte budget in the writer, exact\n  `[NHP-STAT]` totals regardless, and 14-day / 256 MiB retention.\n- **Recovery starts with a reboot** (`aws ec2 reboot-instances`, no SSH needed):\n  DHCP runs before `nhp-serverd`, so tcp/22 is open for the window before the\n  filter is re-attached. Volume surgery is only for a wrong *whitelist* on a\n  host that does have its address.\n\nThe gates run before anything is scp'd or restarted — the `configure` job's\nTerraform/DNS resolution and containment check, `deploy-server`'s\n`Check this job's own SSH source is in the XDP whitelist`, and the refusals in\n`startXdpFilter`, `applyXdpConfig` and `ParseRelayPrefixes`\n(`nhp/utils/ebpf/relayips.go`). A red run with the demo still up is the intended\noutcome of any of them.\n\nCapabilities (`CAP_BPF CAP_NET_ADMIN CAP_PERFMON`) plus a root `ExecStartPre`\nthat prepares `/sys/fs/bpf` come from `terraform/demo/userdata/server.sh` on new\nhosts and from a systemd drop-in installed by `deploy-server` on existing\nones — keep the two in sync. `aws_instance.server` carries\n`lifecycle { ignore_changes = [user_data] }` for the same reason\n`aws_instance.ac` does: an in-place `user_data` update stops and starts the\ninstance, and cloud-init would not re-run the script anyway, so a userdata edit\nwould cost an nhp-server outage and change nothing on the host.\n\nVerification procedure (netns rehearsal, pre-deploy baseline, the same commands\nafter the deploy, recovery paths):\n`terraform/demo/VERIFY-server-xdp.zh-cn.md`.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same `logs/nhp_server_xdp-YYYY-MM-DD.log`\nfiles the daemon writes (and the daemon's per-packet retention is the only\nwindow: 14 days, see *nhp-server eBPF/XDP ingress filter* above). The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log to\n  `/home/ec2-user/nhp-server/logs/nhp_server_xdp-YYYY-MM-DD.log` as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to a dedicated ed25519 public key in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads — not\n  `~/nhp-server/.ssh/...`, which nothing looks at). It accepts only `list`\n  (print date/size/mtime) and `get <YYYY-MM-DD>` (stream one log to stdout);\n  anything else (shell, scp, sftp, paths with `/`, dates that don't match\n  `^\\d{4}-\\d{2}-\\d{2}$`) is rejected at the script. The matching private key\n  is `stats_collector_ssh_private_key` in `opennp/demo`; the public half is\n  installed by `deploy-server` with\n  `command=\".../nhp-xdplog-export.sh\",no-port-forwarding,no-X11-forwarding,\n  no-agent-forwarding,no-pty`. The descriptive `#` marker sits on its OWN\n  line above the entry, because sshd reads any line *starting* with `#` as a\n  comment and would otherwise never authorize the key.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host-key checking is\n  `StrictHostKeyChecking=yes` against a known_hosts seeded from\n  `ssh_host_keys` by `deploy-xdpstats` — never TOFU, because the collector is\n  a timer with nobody to answer a prompt.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*` (SAN expanded by\n  `bootstrap-tls.sh`).\n\n**Three directories, three permission stories.** `/home/ec2-user` is mode\n0700 on AL2023 and nginx workers run as `nginx`, so the web root is\n`/var/www/stats` (0755) and NOT under `$HOME`; `raw/` stays 0700 under\n`$HOME` because it is the only place full, unmasked source IPs exist on the\nrelay; and `state.json` is written outside the web root via\n`xdplog-report site -state`, because it records the absolute path of every\nraw cache file. `deploy/nginx/stats.conf.template` also refuses\n`location = /state.json` as a second line of defence, and repeats the\nsecurity headers inside every `location` — nginx only inherits `add_header`\nfrom an enclosing block when the inner block declares none of its own.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: not touched. The relay's addresses were already in the SSH\n  whitelist when `server_open_all_ports = false` was the default; opening all\n  ports added an internet-wide observation surface but did not relax that\n  list. Collector SSH rides in on the same path the deploy job uses.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to\n  `authorized_keys` BEFORE `deploy-xdpstats` runs (first collector SSH would\n  otherwise hit an unknown public-key fallback).\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nnever applies Terraform (`configure` only reads the state) — so the release\nthat introduces the site has to be `infra-demo action=apply` →\n`deploy-demo-v2 confirm=deploy`. `deploy-xdpstats`'s first step is a preflight\nthat requires `stats.opennhp.org` to resolve to exactly one A record equal to\n`aws_eip.relay.public_ip`, so a forgotten apply is a red run before the relay\nis touched rather than a red curl after the vhost, collector and timer are\ninstalled. DNS-01 cert issuance cannot be relied on to catch it: it writes a\nTXT record through the Cloudflare API and never looks up the A record.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds and post-classifier retroanalysis; it is\n  not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes, not\n  as zero attacks (a missing minute is not the same as \"no attack in that\n  minute\").\n\n**Adding the AC later.** AC writes per-flow accept/deny files in a shape\n`parse.go` does not currently parse. Adding it means (1) a parser extension\nin `endpoints/xdplog-report/parse.go`, (2) a new SSH keypair +\n`authorized_keys` line on the AC host, (3) a second `<name> <user> <host>\n<log_dir>` line in `sources.conf`. The site already routes by `<source>`,\nso no `xdplog-report` change is needed once the parser and keypair exist.\n\n**Manual backfill.** If `xdpstats.timer` missed a window longer than the\ndaemon's 14-day retention, the missing days are unrecoverable; the next\ndeploy should add a one-shot run of `xdplogs-collect.sh -v` after the gap is\nknown. RUNBOOK.md § \"XDP attack stats site\" has the full recovery flow\nincluding key rotation via `opennhp/demo` and a forced rebuild via\n`xdplog-report site -force`.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe to\n> delete from `opennhp/demo` if desired (does not affect any running\n> cluster 1 host); the deploy script tolerates the field being absent.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n","isInternal":false,"tokens":9787,"sizeBytes":37767},{"name":"marketplace.json","path":".claude-plugin/marketplace.json","rawUrl":"https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/marketplace.json","title":"Claude Plugin Marketplace Catalog","category":"marketplace","format":"json","content":"# Claude Plugin Marketplace Catalog\n\nPath: `.claude-plugin/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/marketplace.json)","tokens":300,"sizeBytes":194},{"name":"plugin.json","path":".claude-plugin/plugin.json","rawUrl":"https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/plugin.json","title":"claude-plugin Plugin Manifest","category":"plugin-manifest","format":"json","content":"# claude-plugin Plugin Manifest\n\nPath: `.claude-plugin/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/plugin.json)","tokens":300,"sizeBytes":180},{"name":"mcp.json","path":".cursor/mcp.json","rawUrl":"https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.cursor/mcp.json","title":"Model Context Protocol (MCP) Configuration","category":"mcp-config","format":"json","content":"# Model Context Protocol (MCP) Configuration\n\nPath: `.cursor/mcp.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.cursor/mcp.json)","tokens":300,"sizeBytes":173}],"systemPromptSnippet":"<yakaai_skills repo=\"OpenNHP/opennhp\">\n<!-- File: AGENTS.md (Tokens: ~6802 | Category: root-instruction) -->\n# AGENTS.md\n\nThis file provides guidance to Codex (Codex.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is the\nsingle source of truth `var.nhp_listen_port` in `terraform/demo/variables.tf`.\n`terraform/demo/security-groups.tf` derives both `aws_security_group.server`\nand `aws_security_group.ac` from it, and the `deploy-demo-v2` `configure` job\nreads it via `terraform output` and renders it into `server/config.toml`,\n`ac/server.toml`, and `relay/config.toml` via `${NHP_SERVER_PORT}`. To change\nthe port: edit `var.nhp_listen_port`, run `infra-demo apply` first (so the\nsecurity group rule is in place), then run `deploy-demo-v2` — the order\nmatters, because the rendered config tracks the applied state, never the\nsource-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit does not rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set and break the `Verify the XDP loader gave its capabilities\nback` gate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`).\nInstead, the drop-in adds\n`ExecStartPre=+/sbin/sysctl -q -w net.ipv4.ip_unprivileged_port_start=443`\nand writes `/etc/sysctl.d/99-nhp-server.conf` for persistence; the same line\nis in `terraform/demo/userdata/server.sh` for new hosts, and the two must\nstay in sync (see the `ignore_changes = [user_data]` note in `CLAUDE.md`).\nThe XDP filter allows only the knock port and whitelisted SSH, so widening\nthe unprivileged-port range to 443 does not broaden the host's exposure\nbeyond what the filter already permits.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe\n> to delete from `opennhp/demo` if desired (does not affect any\n> running cluster 1 host); the deploy script tolerates the field being\n> absent.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same\n`logs/nhp_server_xdp-YYYY-MM-DD.log` files the daemon writes. The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to the `stats_collector_ssh_public_key` entry in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads, not\n  `~/nhp-server/.ssh/...`). It accepts only `list` and `get <YYYY-MM-DD>`;\n  anything else (shell, scp, sftp, paths with `/`, malformed dates) is\n  rejected at the script. The `#` marker goes on its own line above the entry\n  — sshd treats a line *starting* with `#` as a comment.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host keys are verified against a known_hosts\n  seeded from `ssh_host_keys`; never TOFU.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*`.\n\n**Three directories, three permission stories.** The web root is\n`/var/www/stats` (0755), not under `$HOME`: `/home/ec2-user` is 0700 on\nAL2023 and nginx runs as `nginx`. `raw/` stays 0700 under `$HOME` — it is\nthe only place full, unmasked source IPs exist on the relay. `state.json` is\nwritten outside the web root via `-state`, and the vhost refuses\n`/state.json` as a second line of defence.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: untouched. Relay addresses were already in the SSH whitelist\n  before the stats site existed.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to `authorized_keys`\n  BEFORE `deploy-xdpstats` runs.\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nonly reads Terraform state, so the release that introduces the site has to be\n`infra-demo action=apply` → `deploy-demo-v2 confirm=deploy`.\n`deploy-xdpstats`'s first step requires `stats.opennhp.org` to resolve to\nexactly one A record equal to `aws_eip.relay.public_ip`, so a forgotten apply\nfails before the relay is touched. DNS-01 cert issuance would not catch it:\nit writes a TXT record through the Cloudflare API and never looks up the A\nrecord.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds; it is not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes,\n  not as zero attacks. RUNBOOK.md § \"XDP attack stats site\" has the full\n  recovery flow including key rotation via `opennhp/demo` and a forced rebuild\n  via `xdplog-report site -force`.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n\n<!-- File: CLAUDE.md (Tokens: ~9787 | Category: claude-rule) -->\n# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nOpenNHP is a Go-based Zero Trust security toolkit implementing two core protocols:\n- **NHP (Network-infrastructure Hiding Protocol)**: Conceals server ports, IPs, and domains from unauthorized access\n- **DHP (Data-content Hiding Protocol)**: Ensures data security via encryption and confidential computing\n\nThe system follows NIST Zero Trust Architecture with three core components that communicate via encrypted UDP packets using the Noise Protocol Framework.\n\n## Git Commit Requirements\n\nAll commits must be signed with a verified GPG or SSH key. Unsigned commits will fail CI checks.\n\n```bash\n# Sign commits (if not configured globally)\ngit commit -S -m \"your message\"\n\n# Amend to sign an existing commit\ngit commit --amend --no-edit -S\n```\n\n## Build Commands\n\n```bash\n# Full build (all components + SDKs + plugins + archive)\nmake\n\n# Build individual components\nmake agentd      # Build nhp-agent daemon\nmake serverd     # Build nhp-server daemon\nmake acd         # Build nhp-ac (access controller) daemon\nmake db          # Build nhp-db daemon\nmake kgc         # Build nhp-kgc (key generation center)\n\n# Build with eBPF support (requires clang)\nmake ebpf\n\n# Build plugins\nmake plugins\n\n# Initialize/tidy modules\nmake init\n```\n\n## Running Tests\n\n```bash\n# Run tests in the nhp module\ncd nhp && go test ./...\n\n# Run tests in the endpoints module\ncd endpoints && go test ./...\n\n# Run specific test file\ncd nhp && go test -v ./test/packet_test.go\n\n# Run benchmark tests\ncd nhp && go test -bench=. ./core/benchmark/\n```\n\n## Code Formatting\n\n**IMPORTANT**: All Go code must be properly formatted before committing. CI will fail if formatting is incorrect.\n\n### Before Committing\n\nAlways run these commands on modified Go files:\n\n```bash\n# Format code with gofmt\ngofmt -w <file.go>\n\n# Fix import grouping with goimports\ngoimports -w <file.go>\n\n# Or format all files in a directory\ngofmt -w ./path/to/package/\ngoimports -w ./path/to/package/\n```\n\n### Import Grouping Style\n\nImports must be organized into three groups separated by blank lines:\n\n1. Standard library imports\n2. External third-party imports\n3. Internal project imports\n\n```go\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pelletier/go-toml/v2\"\n\n\t\"github.com/OpenNHP/opennhp/nhp/common\"\n\t\"github.com/OpenNHP/opennhp/nhp/log\"\n)\n```\n\n### Verify Formatting\n\nCheck if files need formatting (no output means properly formatted):\n\n```bash\ngofmt -l <file.go>\ngoimports -l <file.go>\n```\n\n### Install goimports\n\nIf `goimports` is not installed:\n\n```bash\ngo install golang.org/x/tools/cmd/goimports@latest\n```\n\n## Docker Development\n\n```bash\n# Build and run the full stack\ncd docker && docker-compose up --build\n\n# Individual service testing\ndocker-compose up nhp-server\ndocker-compose up nhp-ac\ndocker-compose up nhp-agent\n```\n\n## Architecture\n\n### Module Structure\n\nThe codebase uses two separate Go modules with a local replace directive:\n\n- **`nhp/`**: Core protocol library\n  - `core/`: Packet handling, cryptography, device management, Noise Protocol implementation\n  - `common/`: Shared types and message definitions (AgentKnockMsg, ServerKnockAckMsg, etc.)\n  - `utils/`: Utility functions\n  - `plugins/`: Plugin handler interfaces (PluginHandler interface)\n  - `log/`: Logging infrastructure\n  - `etcd/`: Distributed configuration support\n\n- **`endpoints/`**: Daemon implementations (depends on nhp module)\n  - `agent/`: NHP-Agent - client that sends knock requests\n  - `server/`: NHP-Server - authenticates and authorizes requests\n  - `ac/`: NHP-AC - access controller that manages firewall rules\n  - `db/`: NHP-DB - Data Broker for DHP\n  - `kgc/`: Key Generation Center for IBC (Identity-Based Cryptography)\n  - `relay/`: TCP relay functionality\n\n### Core Concepts\n\n**Device Types** (defined in `nhp/core/device.go`):\n- `NHP_AGENT`: Client initiating access requests\n- `NHP_SERVER`: Central authentication/authorization server\n- `NHP_AC`: Access controller managing network rules\n- `NHP_DB`: Data Broker for DHP\n- `NHP_RELAY`: Packet relay\n\n**Packet Types** (defined in `nhp/core/packet.go`):\n- `NHP_KNK`: Agent knock request\n- `NHP_ACK`: Server knock acknowledgment\n- `NHP_AOP`: Server-to-AC operation request\n- `NHP_ART`: AC operation result\n- `NHP_REG`/`NHP_RAK`: Agent registration flow\n- `DHP_*`: Data Hiding Protocol messages\n\n**Cipher Schemes** (in `nhp/core/crypto.go`):\n- `CIPHER_SCHEME_CURVE`: Curve25519 + AES-256-GCM + BLAKE2s\n- `CIPHER_SCHEME_GMSM`: SM2 + SM4-GCM + SM3 (Chinese national standards)\n\n### Configuration\n\nAll daemons use TOML configuration files in their respective `etc/` directories:\n- `config.toml`: Base configuration (private key, listen address, log level)\n- `server.toml`: Remote server/peer definitions\n- `resource.toml`: Protected resources and auth service providers\n- `http.toml`: HTTP server settings (for nhp-server)\n\n### Plugin System\n\nServer plugins implement the `PluginHandler` interface (`nhp/plugins/serverpluginhandler.go`) and are built as Go plugins (`.so` files). See `examples/server_plugin/` for reference implementation.\n\nKey plugin methods:\n- `AuthWithNHP()`: Handle NHP protocol authentication\n- `AuthWithHttp()`: Handle HTTP-based authentication\n- `RegisterAgent()`: Agent registration\n- `ListService()`: Service discovery\n\n### Key Generation\n\nAll daemons support the `keygen` command:\n```bash\n./nhp-serverd keygen --curve  # Generate Curve25519 keys\n./nhp-serverd keygen --sm2    # Generate SM2 keys (default)\n```\n\n## Demo Deployment (AWS)\n\nThe `terraform/demo/` stack provisions the public demo (nhp-server, nhp-ac,\nnhp-relay + nginx + Let's Encrypt) in `us-east-2` on the OpenNHP demo AWS\naccount. The state bucket is configured at `terraform init` time via\n`-backend-config=\"bucket=$TF_STATE_BUCKET\"` (workflows read the\n`TF_STATE_BUCKET` repo variable) so the account ID is not committed in source.\nAll secrets live in a single AWS Secrets Manager secret: **`opennhp/demo`**.\n\n> **The nhp-server has no public HTTP/HTTPS surface.** It used to serve a demo\n> login page (`/plugins/example?action=login&resid=demo`) on 443 via nginx,\n> reachable as `auth-plugin.opennhp.org` and the legacy alias\n> `demologin.opennhp.org`. That surface is retired at three layers: the\n> `tcp/443` ingress is gone from `aws_security_group.server`\n> (`terraform/demo/security-groups.tf`), the `demologin` CNAME is gone from\n> `terraform/demo/dns.tf`, and `deploy/config-templates/server/http.toml` sets\n> `EnableHttp = false` so `nhp-serverd` never binds `127.0.0.1:8443`. The\n> `deploy-server` job tears down any leftover `/etc/nginx/conf.d/server.conf`.\n> The server host *serves* only the NHP UDP knock port (plus SSH from the relay\n> jump host) — the security group admits more than that, and the XDP filter drops\n> the rest (see *All-ports exposure* below); `deploy/nginx/server.conf.template` is kept for\n> reference but is no longer deployed. Re-enabling the login page requires\n> undoing all of these together.\n\n**NHP knock port.** The UDP port `nhp-serverd` listens on for knocks is\n`var.nhp_listen_port` in `terraform/demo/variables.tf` — that variable is the\nsingle source of truth. `terraform/demo/security-groups.tf` derives both\n`aws_security_group.server` and `aws_security_group.ac` from it, and the\n`deploy-demo-v2` `configure` job reads it via `terraform output` and renders it\ninto `server/config.toml`, `ac/server.toml`, and `relay/config.toml` via\n`${NHP_SERVER_PORT}`. To change the port: edit `var.nhp_listen_port`, run\n`infra-demo apply` first (so the security group rule is in place), then run\n`deploy-demo-v2` — the order matters, because the rendered config tracks the\napplied state, never the source-default.\n\n**All-ports exposure (deliberate).** `aws_security_group.server` is opened to\n`0.0.0.0/0` on every tcp port **except 22**, every udp port and icmp while\n`var.server_open_all_ports` is `true` (opt-in, default `false`; set it through the `infra-demo` `open_all_ports`\ninput once the attach gate is green, and `infra-demo` refuses it in a plan that\ncreates or replaces `aws_instance.server`). That is intentional and is\nfor observation: scans must reach the NIC so the XDP filter can count them\n(`[NHP-STAT]`, `[NHP-SCAN]`). In this mode **the XDP filter is the only inbound\ncontrol on everything but tcp/22** — the security group is no longer the\nnarrower layer — and it is fail-open and its link is not pinned, so the filter\nis detached on *every* `deploy-demo-v2` run (the daemon is stopped and\nrestarted), failed deploy, crash, `systemctl restart`, reboot and failed load,\nnot only on a new or replaced instance. During each of those windows the host\nis exposed to the internet on every udp port, every tcp port except 22, and\nicmp: chronyd, the SSM/cloud-init agents and anything else listening. **tcp/22\nis deliberately carved out of the open range** and stays restricted to the relay\nSG at the security-group layer, so sshd never depends on a fail-open filter;\nthe cost is that tcp/22 scans never reach the NIC and are not counted. Other IP\nprotocols (GRE, ESP, ...) are not opened. What the filter still lets through to the host from any source in this\nmode: the knock port, SSH from the whitelist, UDP 67→68 (DHCP client), UDP\n123→123 (chronyd), non-first IPv4 fragments and ICMP frag-needed. Those are\naccepted exposure, not narrowed. `infra-demo` also SSHes to the existing server through the relay before an `apply` with `open_all_ports` and refuses unless an XDP program is attached (`Require an attached XDP filter before opening all ports`); it is a point-in-time check, so the windows above remain accepted exposure. Order is the *reverse* of the knock-port rule: run `deploy-demo-v2` first\nand confirm `Verify the XDP ingress filter attached` is green, then\n`infra-demo apply`. Rollback is `server_open_all_ports = false` plus\n`infra-demo apply`, which returns the security group exactly to udp/knock-port +\ntcp/22 from the relay SG; recovery of an unfiltered host is still\n`aws ec2 reboot-instances`. `aws_instance.{server,ac,relay}` carry\n`ignore_changes = [ami]` so a new al2023 release never plans a replace; change\nan AMI deliberately with `terraform apply -replace=aws_instance.<x>`, one host\nat a time (see `terraform/demo/RUNBOOK.md`).\n\n**Binding a privileged port from `User=ec2-user`.** Because `nhp-serverd` runs\nunprivileged on the demo host and `443 < 1024`, the unit cannot rely on\n`CAP_NET_BIND_SERVICE` — granting it would leave the daemon with a non-empty\nambient cap set, breaking the `Verify the XDP loader gave its capabilities back`\ngate (`CapAmb` must be 0, `CapPrm/CapEff` must be `v & ~BPF == 0`). Instead,\n`deploy-server` adds an `ExecStartPre=+/sbin/sysctl -q -w\nnet.ipv4.ip_unprivileged_port_start=443` to the drop-in, and writes\n`/etc/sysctl.d/99-nhp-server.conf` for persistence. The same line is in\n`terraform/demo/userdata/server.sh` so new hosts start the same way; the two\nmust stay in sync (see the note above about `ignore_changes = [user_data]`).\nRelaxing that floor widens the range any unprivileged process on the host\nmay bind to, but the XDP filter is what limits the host's inbound exposure\nnow that `aws_security_group.server` is deliberately open to all ports (see\n*All-ports exposure* above; `aws_security_group.ac` is still narrow), together\nwith the fact that this host runs a single service. The filter is fail-open by\ndesign, which is why the attach gate in `deploy-server` matters. A rollback that moves the port out of the\nprivileged range must therefore remove `/etc/sysctl.d/99-nhp-server.conf`\nand restore the kernel default (1024); the deploy job does this\nautomatically when `NHP_LISTEN_PORT >= 1024`.\n\n#### Deploy binaries are built in an Amazon Linux 2023 container\n\nThe three demo hosts run the AMI selected by `terraform/demo/ami.tf`\n(`al2023-ami-2023.*-x86_64`), whose glibc is **2.34**. `deploy-demo-v2` builds\n`nhp-serverd` / `nhp-acd` / `nhp-relayd` and the server plugins inside\n`docker/Dockerfile.al2023-builder` — an `amazonlinux:2023` image carrying the\n`GO_VERSION` toolchain — not on the runner, because the daemons are **cgo**\nbuilds and the glibc they link against is a floor on the glibc they can run on.\n\nBoth halves of that are load-bearing:\n\n- **cgo cannot be dropped**, at least for `nhp-serverd`. It loads its auth\n  plugins with `plugin.Open` (`nhp/plugins/serverpluginhandler.go`), and Go's\n  `plugin` package is a stub without cgo: the daemon starts clean and then\n  fails every plugin load with `plugin: not implemented`, which is the loss of\n  authentication, not of a feature. Host and `.so` are built in one container\n  for the same reason `scripts/check-plugin-deps.sh` exists.\n- **The builder cannot be newer than the target.** When `ubuntu-latest` moved\n  past glibc 2.34 the deploy stopped the running daemons, uploaded binaries\n  that exec'd into ``/lib64/libc.so.6: version `GLIBC_2.38' not found``, and\n  left nhp-serverd and nhp-acd down — the AC's fail-closed backstop holding\n  tcp/443 shut — on a run whose only symptom was a red `systemctl is-active`.\n\n`scripts/check-glibc-compat.sh` re-checks every ELF the job ships (the three\ndaemons, every plugin `.so`, the `demoapp` binary from `Dockerfile.demoapp`)\nagainst `TARGET_GLIBC` in the workflow env, and the same step asserts\n`nhp-serverd` is still dynamically linked against libc so that \"make the glibc\ncheck pass\" cannot be answered with `CGO_ENABLED=0`. It runs before the upload,\nso a mismatch fails `build` and no deploy job stops anything. The eBPF objects\nare BPF bytecode with no libc in them and are still compiled on the runner.\nRaise `TARGET_GLIBC` only when the demo AMI itself moves.\n\n#### nhp-server eBPF/XDP ingress filter\n\n`nhp-serverd` attaches `nhp/ebpf/xdp/nhp_server_xdp.c` at startup and enforces\nthe same shape at the driver: UDP on the knock port (≥ `NhpMinFrameBytes`), SSH\nonly from the addresses in `etc/xdp.toml`, the replies to flows the host itself\nopened, and nothing else — IPv6 and non-first fragments included. It parses no\nNHP protocol; identity is still decided by the Noise handshake in user space.\nThe AC's per-knock program and the shared loader\n(`nhp/utils/ebpf/engine_linux.go`) are the same code path, selected by\n`EngineLoadParams.Variant`.\n\nInvariants. Each one is load-bearing and most were paid for once already; the\nreasoning, and the incident behind each, is in\n[terraform/demo/RUNBOOK.md](terraform/demo/RUNBOOK.md) under *nhp-server ingress\nfilter (XDP)*.\n\n- **Attaching is opt-in; `etc/xdp.toml` is the opt-in.** No file, an unparsable\n  one, `Enabled = false`, or a `RelayIPs` whose entries do not all parse as IPv4\n  addresses or prefixes ⇒ nothing is attached and the host keeps the exposure it\n  had before the filter existed. `loadXdpConfig`/`startXdpFilter` in\n  `endpoints/server/config.go` also refuse in front of a non-loopback HTTP or\n  metrics listener, and `updateHttpConfig` refuses the mirror case — starting\n  such a listener while the filter is attached.\n- **The whitelist must cover the SSH source, and there is no break-glass path.**\n  A list rendered empty, or without an entry containing the address the host\n  sees SSH arrive from, means detaching the root volume or rebuilding the\n  instance. Entries are host addresses or CIDR prefixes in an LPM trie; one\n  unparsable entry fails the whole list rather than being skipped.\n- **Refusal reasons stay limited to the ones that protect SSH reachability.**\n  With `server_open_all_ports = true` the filter is the only inbound control, so\n  every new reason to refuse attaching is a way to leave all ports open. Telemetry\n  settings (`ScanReportIntervalSeconds`, `ScanReportTopN`) never decide this: an\n  out-of-range value is logged and disables only the `[NHP-SCAN]` summary\n  (`resolveScanReport`), and the filter attaches anyway.\n- **The whitelist is installed before the program is attached**, as\n  `EngineLoadParams.RelayIPs`, never written afterwards. Startup therefore never\n  calls `applyXdpConfig`; that path is the config watcher's, and it always has a\n  live filter and a working list to fall back on.\n- **A reload never weakens a working whitelist.** `applyXdpConfig` keeps the\n  active list on any refusal, and `ReplaceRelayIPs` is all-or-nothing against\n  the map as well as the file: it adds before it sweeps, rolls back its own adds\n  on a failed `Update`, and refuses a map it cannot read.\n- **Loading is fail-open**, and `deploy-server`'s `Verify the XDP ingress filter\n  attached` is what stops that from being a silent loss of protection. Every\n  fallible step happens before `link.AttachXDP`, and any error after it detaches.\n- **The filter must let the host's own traffic back in.** Replies to the\n  daemon's own flows via `bpf_sk_lookup_{tcp,udp}` (connected sockets only,\n  never listening ones); DHCP (67→68) and NTP by port pair, because their\n  clients have no connected socket. Dropping the DHCP answer costs the host its\n  address and every port with it, about an hour after a deploy that verified\n  clean.\n- **The capabilities go back on every path, including the ones that never\n  load.** `dropLoaderPrivileges` (`nhp/utils/ebpf/caps_linux.go`) subtracts\n  `CAP_BPF`, `CAP_NET_ADMIN` and `CAP_PERFMON` from ambient, permitted,\n  effective and inheritable — keeping CAP_BPF only where\n  `kernel.unprivileged_bpf_disabled` makes bpf(2) privileged and the reload path\n  still needs to write the map — process-wide, not just on the calling thread.\n  A subtraction, never an assignment of a fixed set: on the demo unit those\n  three *are* the whole grant, so what is left is nothing, but nhp-serverd also\n  runs as root in `docker/Dockerfile.server`, where resetting to the empty set\n  would silently and irreversibly take away CAP_DAC_OVERRIDE (volume-mounted\n  config owned by another uid) and CAP_NET_BIND_SERVICE (an `http.toml` reload\n  rebinding 80/443) on a host that never opted into filtering at all.\n  The unit grants all three as *ambient* capabilities before any of this code\n  runs, so a host that declines to filter is holding them too: `loadXdpConfig`\n  therefore defers `DropLoaderPrivileges` across all of its returns, not just\n  the loader's. A load that *failed*, and every refusal that never reached the\n  loader, keeps nothing, CAP_BPF included — there is no map to reload, and that\n  is the state the daemon stays in for the life of the process. The drop is\n  once-guarded and the loader goes first, so the attach path's answer is the one\n  that decides CAP_BPF.\n- **The event log is bounded**, because every line in it is a packet someone\n  else chose to send: per-class and global token buckets in the C, bulk classes\n  counted but never written per packet, a daily byte budget in the writer, exact\n  `[NHP-STAT]` totals regardless, and 14-day / 256 MiB retention.\n- **Recovery starts with a reboot** (`aws ec2 reboot-instances`, no SSH needed):\n  DHCP runs before `nhp-serverd`, so tcp/22 is open for the window before the\n  filter is re-attached. Volume surgery is only for a wrong *whitelist* on a\n  host that does have its address.\n\nThe gates run before anything is scp'd or restarted — the `configure` job's\nTerraform/DNS resolution and containment check, `deploy-server`'s\n`Check this job's own SSH source is in the XDP whitelist`, and the refusals in\n`startXdpFilter`, `applyXdpConfig` and `ParseRelayPrefixes`\n(`nhp/utils/ebpf/relayips.go`). A red run with the demo still up is the intended\noutcome of any of them.\n\nCapabilities (`CAP_BPF CAP_NET_ADMIN CAP_PERFMON`) plus a root `ExecStartPre`\nthat prepares `/sys/fs/bpf` come from `terraform/demo/userdata/server.sh` on new\nhosts and from a systemd drop-in installed by `deploy-server` on existing\nones — keep the two in sync. `aws_instance.server` carries\n`lifecycle { ignore_changes = [user_data] }` for the same reason\n`aws_instance.ac` does: an in-place `user_data` update stops and starts the\ninstance, and cloud-init would not re-run the script anyway, so a userdata edit\nwould cost an nhp-server outage and change nothing on the host.\n\nVerification procedure (netns rehearsal, pre-deploy baseline, the same commands\nafter the deploy, recovery paths):\n`terraform/demo/VERIFY-server-xdp.zh-cn.md`.\n\n#### XDP attack stats site (stats.opennhp.org)\n\nDaily summaries of the nhp-server XDP ingress filter's `[NHP-DROP]` events are\npublished at `https://stats.opennhp.org/`. Static HTML + JSON, no third-party\nresources, fully offline-built from the same `logs/nhp_server_xdp-YYYY-MM-DD.log`\nfiles the daemon writes (and the daemon's per-packet retention is the only\nwindow: 14 days, see *nhp-server eBPF/XDP ingress filter* above). The homepage\nleads with a hero total of all-time blocked packets (plus a `Since` date) that\n`countup.js` counts up; that script is generated into the web root by\n`xdplog-report site`, loaded same-origin, and is one of the two the CSP permits\n(`script-src 'self'`, the other being `totalnumber.js`, which the homepage also\nloads so it can quote its own `NHP_TOTAL_ATTACK_BLOCKED`). It is progressive\nenhancement — the number is already in the HTML for readers without JS, without\nthat CSP, or with reduced motion.\n\n**One documented exception to self-containment.** The homepage `<head>` carries a\nmeta description, a canonical URL and the Open Graph / Twitter card tags\n(`og:title` / `og:description` / `og:url` / `og:type` / `og:image`, plus\n`twitter:card`), so a search result or a chat link renders with a real\ndescription. Those tag values are absolute URLs, which is what\n`assertSelfContained` (`site_test.go`) has to allow for: it fails on any\nabsolute URL that is **not** `siteURL` rather than on absolute URLs at all, and\nit separately rejects any `<script src>` that is not a relative path. A crawler\nreads those tags, the browser never fetches them, so they do not make the page\ndepend on anything off-host. The `<title>` is deliberately not the `<h1>`:\n`siteDocumentTitle` (\"Zero Trust in Action: Live Attacks on OpenNHP Servers\")\nis what a tab and a search result show, `siteTitle` (\"Invisible Server, Visible\nAttacks\") is the heading and `og:title`. `og:image` points at `/og.png` in the\nweb root, which has to exist there for social previews to render anything.\n\n**Publishing floor.** `xdplog-report site` publishes nothing before `-since`\n(default `2026-10-16`, `defaultSiteSince` in `site.go`): those days get no\ndetail page, no index row, and no share of the total. The XDP filter was\nattached after that log was written, so the earlier record is partial and\ncounting it would understate the totals by an unquantifiable amount. A `-since`\nthat excludes every discovered day is refused **before anything is deleted**, so\na typo can neither blank a populated index nor leave a site of dead links;\n`-since \"\"` publishes everything, for local debugging. A site that already\nserved a day below the floor is migrated by `unpublishDay` (`site.go`): the next\nrun deletes that day's `d/<source>/<day>.{html,json}`, drops its `state.json`\nentry and re-renders the index — a page left in the web root is still reachable\nat its old URL, so unlinked is not unpublished. That deletion is the only thing\nthe subcommand removes; raw logs and the collector cache are never touched, and\na page it cannot delete fails the run after the index is written. The deletions\nrun only once every publishable day has succeeded and immediately before that\nindex is written, and the index is written before `state.json`: any run that\ngives up — an unreadable log, an empty index it refuses to publish — leaves the\nsite it was serving intact, links and total unchanged, instead of removing pages\nthe old index still points at. **The homepage's Source column renders `server`\nas `nhp-server`** (`sourceLabel` in `render_index.go`) while `index.json` keeps\nthe raw directory name — `source` is a published field and renaming it breaks\nexisting parsers.\n\n**Template versioning.** `SiteDayState.Render` records the `siteRenderVersion`\nthat produced each day page, so bumping that constant in `site.go` re-renders\nevery published day on the next run instead of only the days whose raw log\nhappened to change that week. Days whose raw log the collector already pruned\nare re-rendered from their published `d/<source>/<day>.json`\n(`rerenderPageOnlyDay`) — the HTML is a pure function of it — but their numbers\nare never recomputed. A markup change therefore needs neither `-force` nor a\nhand-deleted `state.json`. A page-only day whose *record* is unreadable drops\nout of the index (`errUnreadablePage`), because there is nothing left to\npublish; one whose record reads fine but whose page cannot be **written** keeps\nits row and its share of the total, and only the run fails — otherwise a\ntransient EACCES or ENOSPC on the web root would silently shrink\n`NHP_TOTAL_ATTACK_BLOCKED` and put it back on the next good run.\n\n**`totalnumber.js`** publishes three all-time figures as JavaScript constants\nfor another site to embed with\n`<script src=\"https://stats.opennhp.org/totalnumber.js\">`:\n`NHP_TOTAL_ATTACK_BLOCKED` (exact packets, the same number the hero shows),\n`NHP_TOTAL_ATTACK_SOURCE_IPS` and `NHP_TOTAL_ATTACK_COUNTRIES`. It is\ngenerated into the web root beside `index.html` and served from its own nginx\nlocation (short `Cache-Control`, no CORS header — a classic script needs\nnone, and the *importing* page's CSP governs it). All three are written even\non an empty site, as `0`, so an embedding page never gets a 404 and a dangling\nreference. Only the packet total is a sibling of `index.json` in the sense\nthat index.json stays authoritative and the total stays reproducible by\nsumming `drop_pkts`.\n\n**The other two are unions, not sums, and they are not reproducible from\n`index.json`.** `unique_attack_ips` is a per-day *count*: an address that\nscans the host daily is one attacker, and adding the days counts it once per\nday. So each day's set of sources and countries is captured while its raw log\nstill exists and written to `facts/<source>/<day>.facts` beside `state.json`\n(`facts.go`) — outside the web root on the relay, refused by `/facts/` in the\nnginx vhost, and never published because a 64-bit hash of an IPv4 address is\nreversible by brute force over 2^32. `unpublishDay` deletes a day's file with\nits pages, so the -since floor cannot leave a removed day's attackers in the\ntotal; a day whose facts are unreadable is named on stdout rather than\nsilently dropped, because a smaller published total is exactly the failure\nthis site is written against.\n\n**GeoIP is part of the state's fingerprint.** `SiteDayState.Geo` records the\ngeo source a day was analyzed with, and a day analyzed without one is stale\nthe moment a database appears — so dropping GeoLite2-City on the relay\nre-analyzes the days already published instead of leaving the country total\nat whatever the days after the install saw. The reverse is deliberately not a\ntrigger: a day whose raw log has aged out can never get its attribution back,\nso taking a database away must not wipe what a published day already has.\n`deploy/scripts/xdpstats-collect.sh` passes\n`-geo-mmdb /home/ec2-user/GeoLite2-City_20251226/GeoLite2-City.mmdb` when that\nfile is readable (MaxMind's license means it is uploaded by hand, not shipped\nby the deploy) and says so in the collect log either way.\n\n**Data flow**\n\n- **Server (writer)**: `nhp-serverd` keeps writing the daily log to\n  `/home/ec2-user/nhp-server/logs/nhp_server_xdp-YYYY-MM-DD.log` as it always\n  has. Same 0600 mode, same `[NHP-DROP]` / `[NHP-STAT]` / `[NHP-SCAN]` lines.\n- **Server (exporter)**: `deploy/scripts/nhp-xdplog-export.sh` is the SSH\n  *forced command* attached to a dedicated ed25519 public key in\n  `/home/ec2-user/.ssh/authorized_keys` (the path sshd reads — not\n  `~/nhp-server/.ssh/...`, which nothing looks at). It accepts only `list`\n  (print date/size/mtime) and `get <YYYY-MM-DD>` (stream one log to stdout);\n  anything else (shell, scp, sftp, paths with `/`, dates that don't match\n  `^\\d{4}-\\d{2}-\\d{2}$`) is rejected at the script. The matching private key\n  is `stats_collector_ssh_private_key` in `opennp/demo`; the public half is\n  installed by `deploy-server` with\n  `command=\".../nhp-xdplog-export.sh\",no-port-forwarding,no-X11-forwarding,\n  no-agent-forwarding,no-pty`. The descriptive `#` marker sits on its OWN\n  line above the entry, because sshd reads any line *starting* with `#` as a\n  comment and would otherwise never authorize the key.\n- **Relay (collector)**: a systemd timer (`deploy/scripts/xdpstats.timer`,\n  `OnCalendar=*-*-* 00:20:00 UTC`, `Persistent=true`) runs\n  `xdpstats-collect.sh`, which calls `ssh -i ~/.ssh/stats_collector_key -o\n  IdentitiesOnly=yes ... list` then `... get <date>` per day, gzip-stores\n  them under\n  `/home/ec2-user/xdpstats/raw/server/nhp_server_xdp-<date>.log.gz`, then\n  invokes `xdplog-report site`. Host-key checking is\n  `StrictHostKeyChecking=yes` against a known_hosts seeded from\n  `ssh_host_keys` by `deploy-xdpstats` — never TOFU, because the collector is\n  a timer with nobody to answer a prompt.\n- **Site**: `endpoints/xdplog-report` has a `site` subcommand that emits\n  `index.html` + `index.json` plus one `<day>.html` / `<day>.json` per day\n  under `/var/www/stats/`. nginx serves that directory at\n  `https://stats.opennhp.org/` using the same Let's Encrypt cert lineage as\n  `relay.*` / `agent.*` / `reg.*` / `demo.*` (SAN expanded by\n  `bootstrap-tls.sh`).\n\n**Three directories, three permission stories.** `/home/ec2-user` is mode\n0700 on AL2023 and nginx workers run as `nginx`, so the web root is\n`/var/www/stats` (0755) and NOT under `$HOME`; `raw/` stays 0700 under\n`$HOME` because it is the only place full, unmasked source IPs exist on the\nrelay; and `state.json` is written outside the web root via\n`xdplog-report site -state`, because it records the absolute path of every\nraw cache file. `deploy/nginx/stats.conf.template` also refuses\n`location = /state.json` as a second line of defence, and repeats the\nsecurity headers inside every `location` — nginx only inherits `add_header`\nfrom an enclosing block when the inner block declares none of its own.\n\n**What's deliberately NOT changed**\n\n- `xdp.toml`: not touched. The relay's addresses were already in the SSH\n  whitelist when `server_open_all_ports = false` was the default; opening all\n  ports added an internet-wide observation surface but did not relax that\n  list. Collector SSH rides in on the same path the deploy job uses.\n- The XDP filter, capability grants, `aws_security_group.server`, and the\n  `aws_instance.server` lifecycle are all untouched. The collector is read-only.\n- `deploy-demo-v2` job order. `deploy-xdpstats` chains AFTER `deploy-demoapp`\n  so certbot's `deploy-relay → deploy-jsagent → deploy-demoapp → deploy-xdpstats`\n  serialisation keeps the relay host's exclusive certbot lock sequential.\n  `deploy-server` appends the stats collector public key to\n  `authorized_keys` BEFORE `deploy-xdpstats` runs (first collector SSH would\n  otherwise hit an unknown public-key fallback).\n\n**Deploy order: `infra-demo apply` first.** The `stats.opennhp.org` A record\nis `cloudflare_record.stats` in `terraform/demo/dns.tf`, and `deploy-demo-v2`\nnever applies Terraform (`configure` only reads the state) — so the release\nthat introduces the site has to be `infra-demo action=apply` →\n`deploy-demo-v2 confirm=deploy`. `deploy-xdpstats`'s first step is a preflight\nthat requires `stats.opennhp.org` to resolve to exactly one A record equal to\n`aws_eip.relay.public_ip`, so a forgotten apply is a red run before the relay\nis touched rather than a red curl after the vhost, collector and timer are\ninstalled. DNS-01 cert issuance cannot be relied on to catch it: it writes a\nTXT record through the Cloudflare API and never looks up the A record.\n\n**Default public behaviour**\n\n- Source IPs are masked: IPv4 truncated to `a.b.c.x`, IPv6 to the first 48 bits.\n  `site` mode of `xdplog-report` defaults `-mask-ip` to `true` (different from\n  the single-file CLI, which defaults `false`). The raw log cache on the relay\n  keeps full IPs for `-force` rebuilds and post-classifier retroanalysis; it is\n  not under the nginx vhost.\n- No login, no rate limit, no geo-restriction. The site is a deliberately\n  public read-only summary of attack telemetry.\n- Detail pages add a `← Back to index` link; the homepage lists every day with\n  partial / budget / restart / unknown flags inline.\n- `tcp/22` scans never reach the host's NIC and are not in the totals; the\n  filter being detached across deploys/reboots is shown as missing minutes, not\n  as zero attacks (a missing minute is not the same as \"no attack in that\n  minute\").\n\n**Adding the AC later.** AC writes per-flow accept/deny files in a shape\n`parse.go` does not currently parse. Adding it means (1) a parser extension\nin `endpoints/xdplog-report/parse.go`, (2) a new SSH keypair +\n`authorized_keys` line on the AC host, (3) a second `<name> <user> <host>\n<log_dir>` line in `sources.conf`. The site already routes by `<source>`,\nso no `xdplog-report` change is needed once the parser and keypair exist.\n\n**Manual backfill.** If `xdpstats.timer` missed a window longer than the\ndaemon's 14-day retention, the missing days are unrecoverable; the next\ndeploy should add a one-shot run of `xdplogs-collect.sh -v` after the gap is\nknown. RUNBOOK.md § \"XDP attack stats site\" has the full recovery flow\nincluding key rotation via `opennhp/demo` and a forced rebuild via\n`xdplog-report site -force`.\n\n### `opennhp/demo` schema\n\nThe secret is JSON; fields are added idempotently by scripts and workflows.\nMissing fields are auto-generated on the next `scripts/generate-nhp-keys.sh`\nrun (triggered by the `deploy-demo-v2` workflow).\n\n| Field | Populated by | Used by |\n| --- | --- | --- |\n| `nhp_server_private_key` / `_public_key` | `scripts/generate-nhp-keys.sh` | server `config.toml`; peer tables on ac/relay |\n| `nhp_ac_private_key` / `_public_key` | same | ac `config.toml`; peer table on server |\n| `nhp_relay_private_key` / `_public_key` | same | relay `config.toml`; peer table on server |\n| `nhp_agent_private_key` / `_public_key` | same | native nhp-agent clients; `agent.toml` on server |\n| `nhp_jsagent_private_key` / `_public_key` | same | cluster 1 `endpoints/js-agent/` demo identity (rendered into `config.json` `clusters[0]` at deploy time); trusted by server cluster 1 only |\n| `nhp_jsagent_sm2_public_key` | same (derived via `--both`) | SM2 peer entry in `server/agent.toml`; lets cluster 1 js-agent knock in gmsm mode |\n| `demoapp_key_envelope_key` | `scripts/generate-nhp-keys.sh` (idempotent; generated via `openssl rand -base64 32` on first run) | demoapp `config.toml` `KeyEnvelopeKey`; AES-256 master that wraps each user's NHP private key at rest |\n| `demoapp_session_key` | same | demoapp `config.toml` `SessionKey`; signs the session cookie |\n| `cloudflare_api_token` | manually provisioned once | Terraform + certbot DNS-01 (`Zone:DNS:Edit` + `Zone:Zone:Read`) |\n| `cloudflare_zone_id` | same | Terraform DNS records for `opennhp.org` |\n| `stealth_ca_cert` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_CERT`) | `tls_locally_signed_cert.demo_nhp` |\n| `stealth_ca_key` | `infra-demo` workflow (from GitHub Secrets `STEALTH_CA_KEY`) | `tls_locally_signed_cert.demo_nhp` |\n| `ssh_deploy_private_key` | manually bootstrapped (see `terraform/demo/RUNBOOK.md`); never enters Terraform state | CI SSH into EC2 hosts |\n| `ssh_deploy_public_key` | derived in CI via `ssh-keygen -y` and passed as `TF_VAR_deploy_public_key` | `aws_key_pair.deploy` → `ec2-user` authorized keys |\n| `ssh_host_keys` | `infra-demo` workflow on `apply` | CI `known_hosts` for strict host key checking |\n| `stats_collector_ssh_private_key` | `scripts/generate-nhp-keys.sh` (idempotent; ed25519 via `ssh-keygen -t ed25519 -N \"\"`) | relay host's `~/.ssh/stats_collector_key` (installed by `deploy-xdpstats`); used by `xdpstats-collect.sh` to pull from nhp-server |\n| `stats_collector_ssh_public_key` | same | nhp-server's `~ec2-user/.ssh/authorized_keys` (appended by `deploy-server` with `command=…/nhp-xdplog-export.sh`,no-*); verified to match the private half before install |\n\n> Cluster 2 has been decommissioned. The corresponding fields\n> (`nhp_jsagent2_*`, `nhp_server2_*`, `nhp_ac2_*`) used to be listed here\n> alongside `nhp_jsagent_sm2_public_key`. Legacy values may still be\n> present in `opennhp/demo` for backwards compatibility but the\n> deploy pipeline no longer reads or writes these keys, no Terraform\n> resources reference them, and no config templates render them. Safe to\n> delete from `opennhp/demo` if desired (does not affect any running\n> cluster 1 host); the deploy script tolerates the field being absent.\n\n### Key-generation flow\n\n`scripts/generate-nhp-keys.sh`:\n\n1. Reads existing values from `opennhp/demo`.\n2. Uses each daemon's `keygen --curve --json` to fill any missing pair.\n3. Writes the merged object back to `opennhp/demo` (preserving unrelated fields).\n4. Renders `deploy/config-templates/` via `envsubst` into `deploy/configs/` for\n   scp to the hosts.\n\nPass `--regenerate` to the script (or `regenerate_keys=yes` on the workflow) to\nforce a full rotation. This breaks every registered agent/ac/relay until their\npeer tables are redeployed in lockstep, so use sparingly.\n\n## Protocol Flow\n\n1. Agent sends encrypted knock (`NHP_KNK`) to Server\n2. Server validates, sends operation request (`NHP_AOP`) to AC\n3. AC opens firewall, responds (`NHP_ART`) to Server\n4. Server sends acknowledgment (`NHP_ACK`) with access info to Agent\n5. Agent can now access the protected resource through AC\n\n<!-- File: .claude-plugin/marketplace.json (Tokens: ~300 | Category: marketplace) -->\n# Claude Plugin Marketplace Catalog\n\nPath: `.claude-plugin/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/marketplace.json)\n\n<!-- File: .claude-plugin/plugin.json (Tokens: ~300 | Category: plugin-manifest) -->\n# claude-plugin Plugin Manifest\n\nPath: `.claude-plugin/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.claude-plugin/plugin.json)\n\n<!-- File: .cursor/mcp.json (Tokens: ~300 | Category: mcp-config) -->\n# Model Context Protocol (MCP) Configuration\n\nPath: `.cursor/mcp.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/OpenNHP/opennhp/HEAD/.cursor/mcp.json)\n</yakaai_skills>"}