Files
ocserv/doc/requirements/internal/main.md
T
Nikos Mavrogiannopoulos a3ac48c2d4 Move to a requirements-first approach
Add doc/requirements/, a structured set of normative requirements
extracted from the current ocserv implementation (internal/*.md,
generated with the requirements-from-implementation protocol) and
from the OpenConnect/AnyConnect protocol sources, reconciled into
protocol/unified.md.

Update AGENTS.md so that new features and bug fixes are documented as
requirements first: find or add the relevant REQ-* entry (with
acceptance criteria) and update the implied tests before changing
code, and confirm in merge requests that existing requirements and
use-cases still hold.

This follows partially https://github.com/microsoft/PromptKit

Signed-off-by: Nikos Mavrogiannopoulos <n.mavrogiannopoulos@gmail.com>
2026-06-15 18:54:38 +02:00

24 KiB

title, generator, process, id-prefix, sources
title generator process id-prefix sources
main process requirements requirements-from-implementation main REQ-MAIN
src/main.c
src/main-ban.c
src/main-auth.c
src/main-worker-cmd.c
src/main-sec-mod-cmd.c
src/main-proc.c
src/ip-lease.c
src/vpn.h
doc/design.md#the-main-process
doc/requirements/internal/ipc.md

main Process Requirements

main runs as root and owns the TCP/UDP listeners, TUN device allocation, IP leasing, the per-connection ban list, and worker process lifecycle. It delegates authentication and credential handling to sec-mod entirely (see internal/ipc.md and internal/sec-mod.md). This document covers main's own state: listener setup, the fork/worker-spawn sequence, IP lease allocation, and the ban-score database (src/main-ban.c).

INIT

REQ-MAIN-INIT-001 — Worker fork clears parent listener and sec-mod fds before exec-equivalent setup

Requirement: Immediately after fork() for a new worker, the child MUST: reset signal mask to sig_default_set; close the unused end of the command socketpair (cmd_fd[0]); call clear_lists(s) to release the parent's listener/proc lists; close s->top_fd if open; and close every sec_mod_instances[i].sec_mod_fd / sec_mod_fd_sync — so the worker inherits no listening sockets and no direct sec-mod control channel. Strength: MUST Status: DERIVED Source: src/main.c:1197-1211 Acceptance: [SEC] negative, local — after a worker is spawned, inspect its open file descriptors (/proc/<pid>/fd); confirm no listener socket fds and no sec_mod_fd/sec_mod_fd_sync fds are present, only cmd_fd[1] and the accepted client connection. Links: REQ-IPC-040, REQ-SEC-001

REQ-MAIN-INIT-002 — sec_auth_init_hmac is computed by main and the HMAC key is zeroized in the child

Requirement: The worker child MUST receive ws->sec_auth_init_hmac computed by main from s->hmac_key plus the worker's remote_ip_str/our_ip_str/session_start_time (REQ-IPC-040), and main MUST safe_memset() its copy of s->hmac_key to zero in the child immediately after computing this HMAC, so the worker process never holds the key used to validate SEC_AUTH_INIT HMACs. Strength: MUST Status: DERIVED Source: src/main.c:1254-1269 Acceptance: [SEC] negative, local — after worker spawn, confirm ws->sec_auth_init_hmac is set (HMAC_DIGEST_SIZE bytes, non-zero) but s->hmac_key is all-zero in the worker's memory image (e.g. via core dump inspection in a test harness, or a debug build assertion). A worker that recovered hmac_key could forge SEC_AUTH_INIT HMACs for sec-mod (REQ-IPC-010). Links: REQ-IPC-010, REQ-IPC-040, REQ-MAIN-SEC-001

REQ-MAIN-INIT-003 — sec-mod instance for a session is selected by client IP hash, fixed at fork time

Requirement: The worker's sec_mod_instance_index (and thus which sec-mod instance will own its client_entry_st/SID) MUST be computed once, at fork time, as hash(remote_addr) % sec_mod_instance_count, and copied into ws->secmod_addr/secmod_addr_len. This selection MUST NOT change for the lifetime of the worker, because "each cookie is valid for its IP address and when resuming it must reach the same sec-mod process that contains the corresponding session information under the SID." Strength: MUST Status: DERIVED Source: src/main.c:1217-1234 (comment explains rationale) Acceptance: unit, local — for sec_mod_instance_count > 1, confirm two connections from the same client IP always select the same sec_mod_instance_index, and that a resumed cookie (REQ-IPC-021) is routed to the same instance that issued it (AUTH_COOKIE_REQ -> secmod_addr/secmod_addr_len from worker_startup_msg, not recomputed). Links: REQ-IPC-017, REQ-IPC-021

REQ-MAIN-INIT-004 — Connection limit and TCP-wrapper/ban checks precede fork

Requirement: main MUST reject a new connection — closing the accepted fd without forking a worker — if (a) max_clients > 0 and s->stats.active_clients >= max_clients, (b) check_tcp_wrapper(fd) < 0 (/etc/hosts.{allow,deny}), or (c) for non-Unix, non-proxy-protocol listeners, check_if_banned() is true for ws->remote_addr. These checks MUST run before socketpair()/fork(). Strength: MUST Status: DERIVED Source: src/main.c:1157-1186 Acceptance: negative, local — (a) set max-clients to current active count and attempt a new connection, confirm it is closed and no worker process appears; (b) configure hosts.deny to block the test client IP, confirm rejection; (c) drive check_if_banned true (REQ-MAIN-SEC-002), confirm rejection. In each case confirm no new PID is added to s->proc_list. Links: REQ-MAIN-SEC-002, REQ-MAIN-SEC-003

REQ-MAIN-INIT-005 — Proxy-protocol listeners skip the connect-time ban check

Requirement: When listen_proxy_proto is enabled, main MUST NOT call check_if_banned() using the immediate peer address at accept time (that address is the load balancer's, not the client's) — ban enforcement for proxy-protocol connections happens later once the real client address is known from the PROXY protocol header. Strength: MUST NOT Status: DERIVED Source: src/main.c:1173-1186 (if (ws->conn_type != SOCK_TYPE_UNIX && !GETRCONFIG(s)->listen_proxy_proto)) Acceptance: [SEC] Confirmed — the post-PROXY-header ban check exists at src/main-worker-cmd.c:387-405, in the CMD_SESSION_INFO handler. Sequencing: the worker calls parse_proxy_proto_header() (src/worker-vpn.c:895) to populate ws->remote_addr with the real client address before the TLS handshake, then calls session_info_send() (src/worker-vpn.c:968, right after the handshake completes) which, when listen_proxy_proto is set, includes remote_addr/our_addr in SessionInfoMsg (src/worker-vpn.c:1146-1153). Main's CMD_SESSION_INFO handler, when GETRCONFIG(s)->listen_proxy_proto and tmsg->has_remote_addr, updates proc->remote_addr via proc_table_update_ip() and calls check_if_banned(s, &proc->remote_addr, proc->remote_addr_len) (main-worker-cmd.c:391-400) — which itself adds ban_points_connect per REQ-MAIN-SEC-005 — and if the result is non-zero, calls kill_proc(proc) (SIGTERM, main.h:189-193) to tear down the worker. Ban enforcement is therefore deferred, not bypassed, for proxy-protocol connections: a banned client's connection is terminated after the TLS handshake rather than at accept time. Positive test: configure listen-proxy-proto, ban an IP, send a PROXY-protocol-prefixed connection from that IP; confirm the worker completes the TLS handshake but is then SIGTERM'd (no CONNECT response). [NOTE: unlike the non-proxy path (banned before accept-time work begins), a banned client behind a proxy still consumes one TLS handshake's worth of work before being killed — a minor resource-amplification difference, not a bypass. Not flagged as [SEC-RISK]; informational only.] Links: REQ-MAIN-SEC-002, REQ-MAIN-SEC-005, doc/design.md#load-balancer-integration


NET — IP lease allocation

REQ-MAIN-NET-001 — Leased IPs must not collide with existing leases, the TUN address, network, or broadcast address

Requirement: get_ipv4_lease() MUST reject a candidate address if (a) an identical /32 lease already exists (ip_lease_exists), (b) the candidate equals the network address, or (c) the candidate equals the computed broadcast address (network | ~mask). get_ipv6_lease() MUST additionally reject a candidate /prefix subnet if it equals the TUN device's own subnet (ip_cmp(subnet, tun) == 0) or if that subnet is already leased. Strength: MUST Status: DERIVED Source: src/ip-lease.c:150-184 (is_ipv6_ok, is_ipv4_ok) Acceptance: unit, local — exhaust the configured IPv4 pool to 2 free addresses: the network and broadcast addresses of the subnet; confirm get_ipv4_lease does not return either and instead fails with no lease available after MAX_IP_TRIES. For IPv6, configure a lease subnet equal to the TUN device's subnet and confirm get_ipv6_lease rejects it. Links:

REQ-MAIN-NET-002 — IP lease allocation gives up after MAX_IP_TRIES random attempts

Requirement: get_ipv4_lease()/get_ipv6_lease() MUST attempt at most MAX_IP_TRIES (16) randomly-generated candidate addresses before reporting allocation failure; it MUST NOT loop indefinitely searching for a free address in an exhausted pool. Strength: MUST Status: DERIVED Source: src/ip-lease.c:186-193 (#define MAX_IP_TRIES 16, max_loops = MAX_IP_TRIES) Acceptance: unit, local — configure an address pool with 0 free addresses; confirm get_ipv4_lease returns an error within MAX_IP_TRIES iterations (bounded time), not a hang. With predictable-ips enabled, proc->ipv4_seed is set once at session creation to hash_any(username, ...) (src/sec-mod-auth.c:571-573) and consumed only on the first loop iteration (max_loops == MAX_IP_TRIES, src/ip-lease.c:300-301). If that single deterministic candidate is rejected by is_ipv4_ok() (already leased, or equal to the network/broadcast address), the function does not retry other deterministic addresses derived from the seed — it falls through to the same candidates used in the non-predictable case: up to 5 further attempts via gnutls_rnd() (src/ip-lease.c:303-313, true random) followed by up to 10 attempts via ip_from_seed() chained off the last random value (src/ip-lease.c:314-319), all under the same max_loops/MAX_IP_TRIES counter. Fallback behavior: a colliding predictable seed silently degrades to random IP assignment for that session, matching the "IP stays the same for the same user when possible" wording in doc/sample.config (predictable-ips); after all 16 attempts fail, ERR_NO_IP is returned regardless of predictable_ips. Positive test: with predictable-ips = true and the hashed candidate for a test username already leased to another session, confirm the new session receives a different (non-deterministic) IP within MAX_IP_TRIES iterations rather than hanging or erroring. Links: REQ-SECMOD-SESSION (predictable_ips / ipv4_seed, see src/sec-mod-auth.c:570-579)

REQ-MAIN-NET-003 — Reconnecting client (steal) transfers IP leases without re-fetching from the pool

Requirement: steal_ip_leases(proc, thief) (used when a client reconnects and supersedes an existing session for the same SID/user) MUST talloc_move the existing ipv4/ipv6 lease structures from proc to thief, MUST call reset_tun(proc) on the old process's TUN device (since its fd is only valid in the old worker), and MUST leave proc with its own copy of the lease addressing info (for disconnect scripts/accounting) — not a shared pointer — by allocating a fresh ip_lease_st and memcpy-ing rip/lip/sig fields. Strength: MUST Status: DERIVED Source: src/ip-lease.c:105-148 Acceptance: unit, local — simulate a reconnect (second AUTH_COOKIE_REQ for a SID with an existing proc_st); confirm the new worker's proc_st gets the same IPv4/IPv6 addresses as the old one, the old worker's TUN device is reset, and disconnect accounting for the old session still reports the correct (copied) IP — i.e., freeing thief->ipv4 does not corrupt proc->ipv4. Links:


SEC — ban list

REQ-MAIN-SEC-001 — hmac_key is generated once at startup, never traverses worker IPC, and is zeroed in worker children

Requirement: s->hmac_key MUST be generated exactly once, at main startup, via hmac_init_key() (src/common/hmac.c:34-37 — gnutls_rnd(GNUTLS_RND_RANDOM, ...)), called from src/main.c:1520 — before run_sec_mod() forks any sec-mod instance (src/main.c:1658) and long before any worker is forked per connection (src/main.c:1197). s->hmac_key MUST NOT appear in any *.proto message exchanged over a worker's cmd_fd/cmd_fd_sync sockets (confirmed by internal/ipc.md REQ-IPC-041: no key material in worker_startup_msg or any other IPC message). The only processes that ever hold a copy of hmac_key are main itself and each sec-mod instance — sec-mod receives it as a direct function argument across fork() (src/main-sec-mod-cmd.c:908-909 -> sec_mod_server() -> memcpy into sec->hmac_key at src/sec-mod.c:1029), which it needs to validate SEC_AUTH_INIT HMACs via generate_hmac() (src/sec-mod-auth.c:906). For each worker, immediately after main computes that worker's ws->sec_auth_init_hmac = HMAC(s->hmac_key, remote_ip_str || our_ip_str || session_start_time) (src/main.c:1262-1266), main MUST safe_memset() its own s->hmac_key to zero in the worker child process (src/main.c:1268-1269), so the unprivileged worker never observes the key itself — only the single derived per-session HMAC. Strength: MUST Status: DERIVED Source: src/common/hmac.c:34-37; src/main.c:1520, 1197-1269, 1658; src/main-sec-mod-cmd.c:908-909; src/sec-mod.c:1029; src/sec-mod-auth.c:906; REQ-IPC-041 Acceptance: [SEC] (a) unit, local — confirm hmac_init_key() is called exactly once per main process lifetime, using GNUTLS_RND_RANDOM, before run_sec_mod(); (b) static, local — grep-confirm no *.proto message field carries hmac_key or raw key bytes (REQ-IPC-041); (c) negative, local — after a worker is spawned, inspect its memory image (debug build / core dump) and confirm s->hmac_key is all-zero while ws->sec_auth_init_hmac is a non-zero HMAC_DIGEST_SIZE-byte digest (REQ-MAIN-INIT-002); (d) unit, local — confirm each sec-mod instance's sec->hmac_key equals main's original (pre-zeroing) hmac_key, since sec-mod uses it to validate SEC_AUTH_INIT HMACs. Links: REQ-MAIN-INIT-002, REQ-IPC-040, REQ-IPC-041

REQ-MAIN-SEC-002 — Ban score is additive, saturating, and IP-prefix-aware for IPv6

Requirement: add_ip_to_ban_list() MUST: treat an IPv6 address as its /64 prefix (zero the low 8 bytes via massage_ipv6_address) for banning purposes; add score to the existing entry's score using saturating arithmetic ((e->score + score) > e->score ? ... : e->score — i.e. never wrap around on overflow); and MUST NOT extend e->expires on repeated violations once the entry is already banned (e->score >= max_ban_score), "or the user will never be unbanned if he periodically polls the server." Strength: MUST Status: DERIVED Source: src/main-ban.c:118-224 Acceptance: unit, local — (a) two IPv6 addresses differing only in the low 64 bits MUST share one ban entry; (b) drive e->score to UINT_MAX - 1 then add more points, confirm e->score does not wrap to a small value; (c) once banned, send further violations and confirm e->expires does not move further into the future. Links: REQ-SECMOD-SEC-003

REQ-MAIN-SEC-003 — Ban score reset rules: time-based reset only while not currently banned, or after a completed ban expires

Requirement: add_ip_to_ban_list() MUST reset e->score = 0 and e->last_reset = now if EITHER (a) the entry's previous ban has expired (now > e->expires) while it was still marked banned (IS_BANNED), OR (b) ban_reset_time has elapsed since last_reset AND the entry is currently not banned. It MUST NOT reset the score for an entry that is currently banned and not yet expired, even if ban_reset_time has elapsed — an active ban cannot be prematurely lifted by the reset-time check. Strength: MUST Status: DERIVED Source: src/main-ban.c:164-176 (comment: "to avoid prematurely lifting an active ban") Acceptance: unit, local — (1) accumulate score to reach max_ban_score (now banned, expires = now + ban_time); (2) advance time past ban_reset_time but before expires; send another violation; confirm e->score is NOT reset (still >= max_ban_score, ban continues). (3) advance time past expires; send another violation; confirm e->score IS reset to 0 (+ the new violation's points). Links: REQ-MAIN-SEC-002

REQ-MAIN-SEC-004 — Local interface addresses are exempt from ban checks

Requirement: check_if_banned() MUST return 0 (not banned) without consulting s->ban_db for any address that matches a local interface address/netmask in s->if_addresses (if_address_test_local). Strength: MUST Status: DERIVED Source: src/main-ban.c:287-306 Acceptance: unit, local — add a local interface's address to the ban list directly (e.g. via repeated add_str_ip_to_ban_list); confirm check_if_banned() for that address still returns 0. This prevents a misconfiguration (or an attacker spoofing a server-local source address) from causing the server to ban itself / a trusted load-balancer IP. Links: REQ-MAIN-INIT-005

REQ-MAIN-SEC-005 — check_if_banned itself contributes ban_points_connect

Requirement: Every call to check_if_banned() for a non-exempt, non-malformed address MUST itself call add_ip_to_ban_list(..., ban_points_connect) — i.e., merely attempting a connection contributes points toward a future ban, independent of whether this particular connection is accepted or rejected. Strength: MUST Status: DERIVED Source: src/main-ban.c:322-325 Acceptance: unit, local — make ban_points_connect repeated connection attempts (each individually allowed); confirm the (connect_count * ban_points_connect) >= max_ban_score threshold results in the next connection being rejected by check_if_banned, even though each prior connection was itself accepted. Links: REQ-MAIN-SEC-002, REQ-IPC-080

REQ-MAIN-SEC-006 — Unban clears score and expiry but does not delete the entry

Requirement: remove_ip_from_ban_list() MUST set e->score = 0 and e->expires = 0 for a matching entry (after applying the same /64 IPv6-prefix massaging as REQ-MAIN-SEC-002), and return non-zero, but MUST NOT remove the entry from s->ban_db — the entry remains for last_reset/future scoring bookkeeping. Returns 0 if no entry matches or size is not 4 or 16. Strength: MUST Status: DERIVED Source: src/main-ban.c:253-285 Acceptance: unit, local — occtl unban <ip> on a banned IP; confirm check_if_banned now returns 0 for that IP, but the entry still exists in s->ban_db (e.g. confirmed via a subsequent occtl show ban points if such exists, or by re-violating and confirming last_reset carries over rather than being treated as a brand-new entry). Links: REQ-MAIN-SEC-002, REQ-MAIN-SEC-003

REQ-MAIN-SEC-007 — cleanup_banned_entries removes only fully-expired-and-stale entries

Requirement: cleanup_banned_entries() MUST delete a ban entry only if BOTH now >= e->expires AND now > e->last_reset + ban_reset_time — an entry whose ban has expired but whose reset window has not yet elapsed MUST be retained (so its score history is not lost prematurely). Strength: MUST Status: DERIVED Source: src/main-ban.c:344-363 Acceptance: unit, local — create an entry with expires in the past but last_reset + ban_reset_time in the future; run cleanup_banned_entries(); confirm the entry is retained. Advance time past last_reset + ban_reset_time; confirm it is now removed. Links: REQ-MAIN-SEC-003


TEARDOWN

REQ-MAIN-TEARDOWN-001 — Workers exceeding auth_timeout without completing authentication are killed

Requirement: kill_children_auth_timeout() MUST terminate (remove_proc(..., RPROC_KILL)) any proc_st with status < PS_AUTH_COMPLETED whose conn_time is older than now - auth_timeout, during periodic maintenance. Strength: MUST Status: DERIVED Source: src/main.c:982-998 Acceptance: negative, local — start a connection, stall before completing SEC_AUTH_CONT/AUTH_COOKIE_REQ past auth-timeout; confirm the worker process is killed by the next maintenance tick. Cross-reference REQ-IPC-011 (sec-mod's independent auth_timeout replay-window check — both checks use the same configuration value but enforce it in different processes). Links: REQ-IPC-011

REQ-MAIN-TEARDOWN-002 — SIGTERM with server-drain-ms stops listeners before terminating sessions

Requirement: On SIGTERM, if server_drain_ms > 0, main MUST first stop and close all listening sockets (no new connections accepted) and start a server_drain_ms timer, deferring terminate_server() (which kills existing worker sessions) until that timer fires. If server_drain_ms == 0, terminate_server() MUST run immediately on SIGTERM. Strength: MUST Status: DERIVED Source: src/main.c:1037-1068 Acceptance: unit, local — with server-drain-ms > 0, send SIGTERM; confirm new connection attempts fail (listener closed) while existing sessions continue for up to server_drain_ms, then are terminated. With server-drain-ms = 0, confirm immediate termination. Cross-reference doc/sample.config server-drain-ms. Links: REQ-MAIN-TEARDOWN-003

REQ-MAIN-TEARDOWN-003 — terminate_server force-kills remaining workers after a 5s grace period

Requirement: terminate_server() MUST send termination signals to all children via kill_children(), then wait up to 5000ms (polling waitpid(-1, NULL, WNOHANG)) for them to exit; if children remain after 5000ms, it MUST kill(0, SIGKILL) (signal the entire process group) rather than waiting indefinitely. Strength: MUST Status: DERIVED Source: src/main.c:1000-1028 Acceptance: unit, local — spawn a worker that ignores SIGTERM (test-only signal handler); trigger server termination; confirm the worker is force-killed via SIGKILL within ~5 seconds and ev_break is reached (main exits). Links: REQ-MAIN-TEARDOWN-002


CFG — reload

REQ-MAIN-CFG-001 — SIGHUP reloads sec-mod before main's own config

Requirement: On SIGHUP, main MUST, for each sec-mod instance: send SIGHUP to the sec-mod process AND send CMD_SECM_RELOAD (via secmod_reload()) — and only after all sec-mod instances have been signaled does main call reload_cfg_file() for its own configuration. If secmod_reload() fails for any instance, main MUST trigger a full SIGTERM (ev_feed_signal_event(loop, SIGTERM)) rather than continuing with an inconsistent reload. Strength: MUST Status: DERIVED Source: src/main.c:1070-1091 (comment: "Reload on main needs to happen later than sec-mod. That's because of a test that the certificate matches the used key.") Acceptance: unit, local — trigger SIGHUP and confirm ordering via log timestamps: sec-mod's CMD_SECM_RELOAD_REPLY (REQ-SECMOD, see src/sec-mod.c:491-501) is observed before main's reload_cfg_file log entry. Negative: make secmod_reload() fail (e.g. stop one sec-mod instance before SIGHUP); confirm main initiates full shutdown rather than a partial reload. Links: REQ-SECMOD-INIT-001

Completeness notes

  • Listener setup (_listen_ports, listen_ports, set_udp_socket_options, set_common_socket_options) is configuration plumbing with no MUST/MUST NOT contract beyond "bind what's configured in doc/sample.config (listen-host, tcp-port, udp-port, etc.)" — [UNDOCUMENTED: candidate REQ-MAIN-INIT-* if specific socket-option requirements (e.g. SO_REUSEADDR, IPV6_V6ONLY) are found to matter for correctness rather than just performance.]
  • UDP session forwarding (forward_udp_to_owner, CMD_UDP_FD/udp_fd_msg): not covered here. [UNDOCUMENTED: candidate REQ-MAIN-NET-* + REQ-IPC-* covering how main demultiplexes UDP packets to the owning worker by source address, and what happens on a DTLS client-hello from an address with no matching worker.]
  • occtl control socket (main-ctl-unix.c): only the termination flow is covered (internal/ipc.md REQ-IPC-070..072). Other occtl commands (show status, show ip bans, reload, show iroutes) are reporting/administrative with no further requirements in this pass.
  • chroot (src/main.c:1675-1691): sec-mod's chdir-to-chroot behavior is noted but not analyzed for completeness — [UNDOCUMENTED: candidate REQ-MAIN-SEC-* covering what happens if chroot_dir is misconfigured (relative vs absolute socket paths).]