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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,11 @@
- **Decision:** qsl-server's store-and-forward queue becomes DURABLE (embedded SQLite via rusqlite/bundled, WAL + synchronous=FULL, single-file store at the required `STORE_PATH`; route tokens persisted only as SHA-256 digests; payloads stored verbatim as opaque blobs), and the delivery contract adds an ACKNOWLEDGED-PULL mode per the qsl-protocol D578 design-lock (option B, operator-chosen): `GET /v1/pull?ack=lease` returns messages WITHOUT deleting and leases them for `PULL_LEASE_SECS` (default 60 s); `POST /v1/pull/ack {"ids":[...]}` deletes ONLY leased copies (idempotent; scoped to the route; unleased duplicate copies per the NA-0275 contract survive); un-acked leases expire and the messages reappear. The LEGACY pull (`GET /v1/pull?max=N`, no ack parameter) keeps its exact delete-on-deliver contract and response shape — the current non-acking qsc client is not stranded. The 5-minute idle-route discard (`ROUTE_IDLE_TTL_MS`) is RETIRED (warn-and-ignore) and replaced by an operator-tunable retention TTL for undelivered messages (`RETENTION_TTL_SECS`, default 7 days, ceiling 30 days); delivered+acked messages are still forgotten immediately — the relay is reliable, not an archive. Startup is fail-closed: `STORE_PATH` has no default.
- **Rationale:** The in-memory queue was demo-class: a restart dropped every queued message and idle routes discarded after 5 minutes, unacceptable for the DOC-PROG-003 self-host operator-path (Tier-1, step 1). Delete-on-pull loses a message if the puller crashes between pull and local persistence; the lease model closes that window without wire-semantic or E2EE change (payloads stay opaque, the relay stays blind, nothing precludes future E2EE read receipts as ordinary payloads). Route/pull semantics are a recorded-decision surface per D-0009/D-0010, so the contract change is recorded here; governance authority for the lane lives in qsl-protocol (NA-0642, D578, D-1265).
- **References:** qsl-protocol NA-0642 / QSL-DIR-2026-07-13-578 (D578) / D-1265; D-0009; D-0010; `src/store.rs`; `src/lib.rs`; `src/main.rs`; `packaging/systemd/relay.env.example`; `packaging/systemd/qsl-server.service` (StateDirectory); `packaging/runbook_ubuntu.md`; `tests/na0642_durability_restart.rs`; `tests/na0642_ack_contract.rs`; `tests/na0642_retention_lifecycle.rs`; `tests/na0642_retention_logging.rs`; `tests/na0642_backward_compat.rs`; `tests/na0642_concurrency.rs`; `tests/na0642_store_privacy.rs`

- **ID:** D-0012
- **Status:** Accepted
- **Date:** 2026-07-17
- **Goals:** G4, G5
- **Decision:** qsl-server gains the ADDITIVE capability document `GET /v1/server-info` (qsl-protocol DOC-PROG-004 step 2, locked decision L4). On a bearer relay an unauthorized request (missing OR wrong token — identical both ways, never a token oracle) receives HTTP 401 with EXACTLY the fixed two-key probe `{"server":"qsl-server","auth":{"mode":"bearer"}}`; the probe registry is FIXED at implementation name + auth mode and may never grow operator config. An authorized request (or any request on an open relay) receives the full document from LIVE configuration: `server`, `version` (CARGO_PKG_VERSION), `name` (`RELAY_NAME`, "" when unset), `api` `["push_v1","pull_v1","pull_ack_lease_v1"]`, `auth.mode` `open|bearer` per `RELAY_TOKEN` presence, `limits.{max_body_bytes,max_queue_depth}`, `retention.ttl_secs`, `directory.mode` `"none"`, `attachments.service_url` (`RELAY_ATTACHMENTS_SERVICE_URL`, null when unset), `kt.mode` `"none"`, `min_client_version` (`RELAY_MIN_CLIENT_VERSION`, null when unset, ADVISORY — not enforced). Contract rules per DOC-SRV-006: additive-only evolution (clients ignore unknown fields; fields never removed/renamed/repurposed); the document gates FEATURES never SECURITY (absence means "not offered", never "not enforced"); served behind the SAME bearer gate as the relay routes; the three existing routes keep plain `ERR_UNAUTHORIZED` byte-identical. `/v1/push`, `/v1/pull`, `/v1/pull/ack` semantics, the storage schema, and the auth mechanism are UNCHANGED; the three new env vars are optional and never fail startup (the `RELAY_TOKEN` env-only precedent).
- **Rationale:** The GUI onboarding path (DOC-PROG-004 step 5) needs a real "test connection" contract: a client must be able to distinguish "QSL relay, needs auth" from "not a QSL relay" without leaking operator config, and an authorized client must learn the relay's actual limits and features from live values rather than assumptions. Locking additive-only evolution and features-never-security into the spec now prevents the capability document from ever becoming a downgrade vector. Route surface is a recorded-decision area per D-0009/D-0010/D-0011, so the addition is recorded here; governance authority lives in qsl-protocol (NA-0652, D588, D-1275).
- **References:** qsl-protocol NA-0652 / QSL-DIR-2026-07-17-588 (D588) / D-1275; D-0011; `src/lib.rs`; `docs/server/DOC-SRV-006_Server_Info_Capability_Contract_v1.0.0_DRAFT.md`; `packaging/systemd/relay.env.example`; `packaging/runbook_ubuntu.md`; `tests/na0652_server_info.rs`
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# DOC-SRV-006 Server Info Capability Contract v1.0.0 (DRAFT)

## Purpose

Define the `GET /v1/server-info` capability document (NA-0652, directive D588;
DOC-PROG-004 step 2, locked decision L4): the mechanism by which a client asks
a relay what it offers. The route is ADDITIVE: `/v1/push`, `/v1/pull`, and
`/v1/pull/ack` semantics are unchanged by this contract.

## Contract Rules (normative)

1. **Additive-only evolution.** Fields are never removed, renamed, or
repurposed. New capabilities arrive as new fields or new values. Clients
MUST ignore unknown fields. The anticipated per-client admission work
(qsl-protocol ENG-0036) would later extend `auth.mode` with a new value
additively; nothing in this contract implements it.
2. **Features, never security.** The document advertises features and limits.
Nothing in it may relax, disable, or substitute for a security behavior.
Absence of a field means "not offered", never "not enforced". A client
MUST NOT weaken any security posture based on document contents.
`min_client_version` is ADVISORY data for clients; the server does not
enforce it.
3. **Served behind the same gate.** The full document sits behind the same
bearer gate (`RELAY_TOKEN`) as the relay routes. On a bearer relay an
unauthorized request receives the fixed probe (below); the probe registry
is FIXED at implementation name + auth mode and may never grow operator
config. The probe is a deliberate minimal disclosure (locked decision L4):
an unauthenticated probe of `/v1/push` already returns `ERR_UNAUTHORIZED`
today; the marginal disclosure is the implementation name.
4. **Probe-vs-full semantics.** The three existing routes keep their existing
auth behavior byte-identical (`401` with plain `ERR_UNAUTHORIZED`); they do
NOT adopt the probe body. Only `/v1/server-info` serves JSON on the
unauthorized path.

## Route

- `GET /v1/server-info`. No query parameters are defined. Responses are
`application/json`.

## Probe (bearer relay, unauthorized)

Returned with HTTP `401` for a missing OR wrong token — identical both ways,
so the response is never a token oracle:

```json
{"server":"qsl-server","auth":{"mode":"bearer"}}
```

This is the complete registry. It identifies "a QSL relay that requires auth"
(distinguishable from not-a-relay) and nothing else.

## Full Document (open relay: any request; bearer relay: valid token)

Returned with HTTP `200`. Every value reflects LIVE configuration, never a
constant. Canonical shape and value sources:

| Field | Source |
|---|---|
| `server` | `"qsl-server"` (implementation name) |
| `version` | `CARGO_PKG_VERSION` |
| `name` | `RELAY_NAME` env var; `""` when unset (empty-safe) |
| `api` | `["push_v1", "pull_v1", "pull_ack_lease_v1"]` (the served API set) |
| `auth.mode` | `"bearer"` iff `RELAY_TOKEN` set non-empty, else `"open"` |
| `limits.max_body_bytes` | live `MAX_BODY_BYTES` config |
| `limits.max_queue_depth` | live `MAX_QUEUE_DEPTH` config |
| `retention.ttl_secs` | live `RETENTION_TTL_SECS` config (validated value the store enforces) |
| `directory.mode` | `"none"` (no directory service offered) |
| `attachments.service_url` | `RELAY_ATTACHMENTS_SERVICE_URL` env var; `null` when unset. The relay does not proxy or validate it; it is data for the client |
| `kt.mode` | `"none"` (no key-transparency service offered) |
| `min_client_version` | `RELAY_MIN_CLIENT_VERSION` env var; `null` when unset; advisory only |

## Configuration

Three new env vars, matching the `RELAY_TOKEN` env-only precedent. All three
are optional; absent or empty values never fail startup:

- `RELAY_NAME` — operator-set display string for the relay.
- `RELAY_ATTACHMENTS_SERVICE_URL` — attachments service URL to advertise.
- `RELAY_MIN_CLIENT_VERSION` — advisory minimum client version.

## Test Anchors

`tests/na0652_server_info.rs`: probe-vs-full in both auth modes; wrong-token
byte-identity (no oracle); exact-field-set guards on the probe (both nesting
levels) and the full document top level; injected-config value tracking;
end-to-end env plumbing through the real binary.

## Decision

Recorded as qsl-server DECISIONS `D-0012` (wire-contract surface, following
`D-0009`/`D-0010`/`D-0011`). Governance authority: qsl-protocol lane NA-0652,
directive QSL-DIR-2026-07-17-588.
3 changes: 3 additions & 0 deletions packaging/runbook_ubuntu.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Edit `/etc/qsl-server/relay.env`:
- `PULL_LEASE_SECS=60` (visibility timeout for acknowledged pulls, `?ack=lease`)
- `MAX_BODY_BYTES`, `MAX_QUEUE_DEPTH` as needed (template default 257 — the
NA-0598 exact-4-MiB attachment needs 256 chunks + 1 manifest)
- `RELAY_NAME=`, `RELAY_ATTACHMENTS_SERVICE_URL=`, `RELAY_MIN_CLIENT_VERSION=`
(optional, NA-0652 — reported by `GET /v1/server-info` to authorized clients;
see DOC-SRV-006. Unset is fine; startup never fails on them)

Apply config:

Expand Down
9 changes: 9 additions & 0 deletions packaging/systemd/relay.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@ PULL_LEASE_SECS=60
# Optional bearer token gate for /v1/push and /v1/pull.
# Leave empty to disable auth.
RELAY_TOKEN=

# Optional server-info fields (NA-0652; GET /v1/server-info reports them to
# authorized clients). All three may be left unset; startup never fails on them.
# Operator-set display name for this relay ("" in the document when unset).
#RELAY_NAME=
# Attachments service URL to advertise (null in the document when unset).
#RELAY_ATTACHMENTS_SERVICE_URL=
# Advisory minimum client version (null when unset; NOT enforced by the server).
#RELAY_MIN_CLIENT_VERSION=
96 changes: 96 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,33 @@ fn push_rate_refill_or_cap(value: usize) -> usize {
value.min(MAX_PUSH_RATE_REFILL_PER_SEC_CEILING)
}

// NA-0652 server-info descriptive fields. Reporting-only: nothing here gates
// any relay behavior (the capability document advertises FEATURES, never
// SECURITY — DOC-SRV-006). All three are optional; absent or empty env vars
// never fail startup, matching the RELAY_TOKEN env-only precedent.
#[derive(Clone, Debug, Default)]
pub struct ServerInfoCfg {
pub name: Option<String>,
pub attachments_service_url: Option<String>,
pub min_client_version: Option<String>,
}

impl ServerInfoCfg {
pub fn from_env() -> Self {
Self {
name: std::env::var("RELAY_NAME")
.ok()
.filter(|v| !v.trim().is_empty()),
attachments_service_url: std::env::var("RELAY_ATTACHMENTS_SERVICE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
min_client_version: std::env::var("RELAY_MIN_CLIENT_VERSION")
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
}

#[derive(Clone)]
pub struct AppState {
// Durable store-and-forward queue (SQLite). Payloads are opaque blobs;
Expand All @@ -229,6 +256,11 @@ pub struct AppState {
limits: Limits,
controls: ResourceControls,
relay_token: Option<String>,
// Validated copy of StoreConfig::retention_ttl_secs so /v1/server-info can
// report the live value the store enforces (same retention_ttl_or_error
// path Store::open applies).
retention_ttl_secs: usize,
server_info: ServerInfoCfg,
}

impl AppState {
Expand Down Expand Up @@ -284,12 +316,31 @@ impl AppState {
relay_token: Option<String>,
store_cfg: StoreConfig,
) -> Result<Self, String> {
Self::new_with_auth_controls_store_and_info(
limits,
controls,
relay_token,
store_cfg,
ServerInfoCfg::from_env(),
)
}

pub fn new_with_auth_controls_store_and_info(
limits: Limits,
controls: ResourceControls,
relay_token: Option<String>,
store_cfg: StoreConfig,
server_info: ServerInfoCfg,
) -> Result<Self, String> {
let retention_ttl_secs = retention_ttl_or_error(store_cfg.retention_ttl_secs)?;
Ok(Self {
store: Store::open(&store_cfg)?,
push_rates: Arc::new(Mutex::new(HashMap::new())),
limits,
controls,
relay_token,
retention_ttl_secs,
server_info,
})
}

Expand Down Expand Up @@ -374,9 +425,54 @@ pub fn app(state: AppState) -> Router {
.route("/v1/push", post(push_message))
.route("/v1/pull", get(pull_message))
.route("/v1/pull/ack", post(ack_messages))
.route("/v1/server-info", get(server_info))
.with_state(state)
}

// NA-0652 capability document (DOC-SRV-006). Served behind the same bearer
// gate as the relay routes; on a bearer relay an unauthorized request gets the
// FIXED two-key probe — identical for a missing and a wrong token, so the
// response is never a token oracle, and the registry never grows operator
// config. The full document reports LIVE config only; it gates features,
// never security.
async fn server_info(State(st): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
if !auth_ok(&headers, st.relay_token.as_deref()) {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"server": "qsl-server",
"auth": { "mode": "bearer" }
})),
)
.into_response();
}
let auth_mode = if st.relay_token.is_some() {
"bearer"
} else {
"open"
};
(
StatusCode::OK,
Json(serde_json::json!({
"server": "qsl-server",
"version": env!("CARGO_PKG_VERSION"),
"name": st.server_info.name.clone().unwrap_or_default(),
"api": ["push_v1", "pull_v1", "pull_ack_lease_v1"],
"auth": { "mode": auth_mode },
"limits": {
"max_body_bytes": st.limits.max_body_bytes,
"max_queue_depth": st.limits.max_queue_depth,
},
"retention": { "ttl_secs": st.retention_ttl_secs },
"directory": { "mode": "none" },
"attachments": { "service_url": st.server_info.attachments_service_url },
"kt": { "mode": "none" },
"min_client_version": st.server_info.min_client_version,
})),
)
.into_response()
}

fn channel_log_id(channel: &str) -> String {
// Deterministic redaction-safe identifier (FNV-1a 64-bit rendered as hex).
let mut state: u64 = 0xcbf29ce484222325;
Expand Down
Loading