Files
alknet/docs/architecture/decisions/031-forwarding-policy.md
glm-5.1 19b3d3a078 docs: write Phase 0 architecture foundation — ADRs 026-034, spec docs, and task updates
Phase 0a — ADRs (9 new):
- ADR-026: Transport/interface separation (three-layer model)
- ADR-027: Crate decomposition (core, secret, storage, flowgraph, napi, CLI)
- ADR-028: Auth as irpc service (AuthProtocol behind feature flag)
- ADR-029: Identity as core type (Identity + IdentityProvider in alknet-core)
- ADR-030: Static/dynamic config split (ArcSwap, ConfigReloadHandle)
- ADR-031: Forwarding policy (rule-based allow/deny, TransportKind-aware)
- ADR-032: Event boundary discipline (domain, irpc, call protocol boundaries)
- ADR-033: OperationEnv universal composition (three dispatch paths)
- ADR-034: Head/worker terminology (replace hub/spoke)

Phase 0b — New spec documents (7):
- identity.md, services.md, interface.md, configuration.md,
  storage.md, flowgraph.md, secret-service.md

Updated existing docs:
- auth.md: reference identity.md for canonical definitions, add AuthProtocol
- open-questions.md: resolve OQ-12, OQ-16, OQ-18, OQ-22, OQ-23-25
- README.md: add all new docs, ADRs 026-034

Marked 19 architecture tasks as completed.
2026-06-07 09:32:58 +00:00

5.1 KiB

ADR-031: Forwarding Policy

Status

Accepted

Context

Currently, any authenticated client can open a direct-tcpip SSH channel to any destination. The only gate is authentication — once authenticated, a client has unrestricted network access through the tunnel. This is a security gap: a compromised key grants unrestricted access.

Operators need the ability to:

  • Restrict which hosts and ports authenticated clients can access
  • Apply different rules to different principals (key fingerprints, accounts)
  • Restrict WebTransport clients to alknet control channels only
  • Set a default policy (allow-all for migration compatibility, deny-all for production)

Decision

Add ForwardingPolicy as part of DynamicConfig (reloadable without restart).

Type Definitions

pub struct ForwardingPolicy {
    pub default: ForwardingAction,
    pub rules: Vec<ForwardingRule>,
}

pub struct ForwardingRule {
    pub target: TargetPattern,
    pub action: ForwardingAction,
    pub principals: Vec<String>,   // Empty = matches all
    pub transports: Vec<TransportKind>,  // Empty = matches all
}

pub enum ForwardingAction {
    Allow,
    Deny,
}

pub enum TargetPattern {
    Any,
    Host(String),          // "localhost", "*.example.com"
    Cidr(IpNetwork),       // "10.0.0.0/8"
    PortRange(String, Range<u16>),  // "localhost", ports 8080-8090
    AlknetPrefix,          // Matches alknet-* control channels
}

Rule Evaluation

Rules are evaluated in order. First match wins. If no rule matches, the default applies. This supports both allowlist and blocklist semantics:

  • Allowlist: default: Deny, then explicit Allow rules for permitted destinations.
  • Blocklist: default: Allow, then explicit Deny rules for blocked destinations.

Principals

Each rule can specify which principals it applies to. A principal is an Identity.id (fingerprint or UUID) or a scope from Identity.scopes. When the rule's principals field is empty, it matches all identities.

This connects to the IdentityProvider trait (ADR-029): when a client authenticates, the Identity is resolved, and the forwarding policy checks rules against Identity.id and Identity.scopes.

TransportKind-Aware Rules

Each rule can specify which TransportKind it applies to. This enables transport-specific restrictions — for example, WebTransport clients can be restricted to alknet-* control channels only:

ForwardingRule {
    target: TargetPattern::AlknetPrefix,
    action: ForwardingAction::Allow,
    principals: vec![],
    transports: vec![TransportKind::WebTransport { host: "*".into() }],
}

Where the Policy Check Happens

The forwarding policy check occurs in channel_open_direct_tcpip before the proxy task is spawned. The current behavior (no check) is equivalent to ForwardingPolicy::allow_all() — default Allow, no rules. This preserves backward compatibility during migration.

DynamicConfig Integration

ForwardingPolicy is part of DynamicConfig and reloadable via ConfigReloadHandle::reload() or NAPI's reloadForwarding(). Changes take effect on the next channel open — existing connections continue with their current policy.

OQ Resolutions

  • OQ-12 (Per-user forwarding scope vs global rules): Resolved. Start with global rules + principal matching from Identity.scopes. Per-user scope from peer_credentials.metadata.scopes via IdentityProvider.
  • OQ-16 (Transport-specific forwarding): Resolved. Add TransportKind match in ForwardingRule. WebTransport clients can be restricted.
  • OQ-18 (Source of Identity.scopes): Resolved by ADR-029 and this ADR. IdentityProvider owns scopes. ForwardingPolicy consumes them.

Consequences

  • Positive: Operators can restrict access per identity, per destination, per transport. A compromised key no longer grants unrestricted network access.
  • Positive: Default-allow preserves current behavior during migration. Switch to default-deny for production deployments.
  • Positive: Policy is reloadable without restart. Adding a rule via reloadForwarding() takes effect on the next channel open.
  • Positive: TransportKind-aware rules enable transport-specific restrictions (e.g., WebTransport clients restricted to alknet-* channels).
  • Negative: Another check in the hot path (every channel_open_direct_tcpip call). The cost is a linear scan of rules — acceptable for small rule sets. Large rule sets should use compiled matchers (future optimization).
  • Negative: TargetPattern string matching is lenient. Host patterns like *.example.com require careful implementation to prevent bypasses. The glob or globset crate can handle this correctly.

References