diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07bc72f..43d6477 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,14 +1,24 @@
# CI Pipeline for akon
-# Validates code quality, runs tests, and verifies builds on every push and pull request
+# Validates code quality, runs tests, and verifies builds on every push and PR.
+#
+# Test strategy (must match the runtime: a GitHub runner has NO root TUN access,
+# NO netlink privileges, NO production F5 appliance, and NO GNOME Keyring daemon):
+# - We run the PURE protocol layers + the OFFLINE native e2e/equivalence/
+# dataplane suites (built with `--features test-actors`, driven by the
+# in-memory test actors + a loopback TLS server — no root, no network egress).
+# - The privileged/online suites SELF-SKIP here: the real-TUN, netns, podman,
+# and production sign-off tests gate on env flags / capabilities / podman and
+# return early when those are absent.
+# - The real GNOME-Keyring tests self-skip when no secret-service daemon is
+# present (the case on CI); keyring logic is covered deterministically by the
+# dedicated `mock-keyring` job below.
name: CI
-# Trigger on pull requests
on:
pull_request:
jobs:
- # User Story 1: Code Quality Validation
- # Validates code formatting and linting rules
+ # Code quality: formatting + clippy (lint the gated test code too).
lint:
name: Lint (rustfmt + clippy)
runs-on: ubuntu-latest
@@ -28,15 +38,14 @@ jobs:
- name: Check code formatting
run: cargo fmt --all --check
- - name: Run clippy linter
- run: cargo clippy --workspace --all-targets -- -D warnings
+ - name: Run clippy linter (incl. test-actors test code)
+ run: cargo clippy --workspace --all-targets --features test-actors -- -D warnings
- # User Story 2: Automated Test Execution
- # Runs all unit and integration tests across workspace
+ # Automated tests: pure layers + offline native suites. Privileged/online and
+ # real-keyring tests self-skip on the runner (see strategy note above).
test:
name: Test
runs-on: ubuntu-latest
- continue-on-error: true
strategy:
matrix:
rust: [stable]
@@ -54,11 +63,10 @@ jobs:
with:
toolchain: ${{ matrix.rust }}
- - name: Run tests
- run: cargo test --workspace --verbose
+ - name: Run tests (pure + offline native, test-actors enabled)
+ run: cargo test --workspace --features test-actors --verbose
- # User Story 2b: Run feature-gated integration test using mock-keyring
- # This job runs the integration test that depends on the `mock-keyring` feature.
+ # Keyring logic, deterministically, via the in-memory mock-keyring backend.
mock-keyring-test:
name: Test (mock-keyring integration)
runs-on: ubuntu-latest
@@ -78,11 +86,9 @@ jobs:
toolchain: ${{ matrix.rust }}
- name: Run mock-keyring integration test
- # Run only the integration test that is gated by the `mock-keyring` feature
run: cargo test -p akon-core --test integration_keyring_tests --features mock-keyring -- --nocapture
- # User Story 3: Build Verification
- # Verifies successful compilation in release mode
+ # Build verification (release; test-only code is gated out).
build:
name: Build (release)
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a94992b..5ee746a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -59,9 +59,9 @@ jobs:
curl \
rpm-build \
rpmdevtools
- # Install runtime dependencies (openconnect + dbus)
+ # Install build dependencies (dbus + setcap)
dnf install -y \
- openconnect \
+ libcap \
dbus-devel \
pkgconf-pkg-config
@@ -187,9 +187,9 @@ jobs:
curl \
rpm-build \
rpmdevtools
- # Install runtime dependencies (openconnect + dbus)
+ # Install build dependencies (dbus + setcap)
dnf install -y \
- openconnect \
+ libcap \
dbus-devel \
pkgconf-pkg-config
diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md
index b72c8ec..248556b 100644
--- a/.specify/memory/constitution.md
+++ b/.specify/memory/constitution.md
@@ -1,16 +1,22 @@
# Auto-OpenConnect (Akon) Constitution
@@ -98,6 +107,23 @@ FOLLOW-UP TODOS: None
**Rationale**: CLI-first design enables automation (systemd timers, NetworkManager dispatchers) and scripting without GUI dependencies.
+### VI. Test Actors & Seam-Isolated Testing (NON-NEGOTIABLE)
+
+**Every behavior that depends on a heavy or real-world integration MUST be testable offline, deterministically, and without hanging — by isolating the integration behind a seam and emulating it with an in-memory test actor that serves as ground truth.**
+
+This principle codifies the methodology that produced the test actors framework (`akon-core/src/vpn/testkit/`) and the native F5 backend. It applies to anything that would otherwise require real infrastructure to test: the operating system (process spawn/signal/discovery, TUN devices, routing), the network (TLS sockets, HTTP endpoints, DTLS, reachability), external binaries (`openconnect`, `pgrep`, `kill`), root privileges, or wall-clock time.
+
+- **Seams over real I/O**: Heavy integrations MUST be accessed through an explicit interface (a Rust trait such as `Transport`, `TunDevice`, `SystemEffects`, or `VpnBackend`) — never via hard-coded direct calls scattered through logic. Each seam has a real production implementation and a test implementation.
+- **Durable, behavior-shaped boundaries**: The primary abstraction MUST be expressed in terms the project will still own after a dependency is removed (e.g. connection lifecycle events), NOT in terms of the current implementation's artifacts (e.g. a child process's stdout). Implementation-specific seams (like `SystemEffects` for the openconnect path) are permitted but MUST be internal details of one implementation and deletable with it.
+- **Actors as ground truth**: Test implementations of seams MUST be in-memory actors (a fake server, a peer, a registry, a controllable network) that emulate real behavior faithfully and reuse the real codecs/state machines wherever possible (e.g. the fake F5 server drives the genuine framing/PPP code). They MUST perform no real I/O, require no root, and never touch the host network.
+- **Backend-agnostic scenario suites**: When replacing a component, the SAME scenario suite MUST validate the old and new implementations against the shared boundary, and equivalence MUST be demonstrated before the replacement may become the default.
+- **No-hang discipline**: Tests MUST be deterministic and bounded. Every wait on I/O MUST have a timeout; every in-memory transport/channel MUST signal EOF/close (including on drop) so consumer loops terminate. A test that can hang is a defect, not an inconvenience — the fix is to bring the integration into the actors model, not to leave a blocking test.
+- **Real end-to-end confirmation**: Emulation proves protocol/logic correctness; it does not by itself acknowledge a replacement. A replacement of a real integration MUST also be confirmed by at least one **real** end-to-end test that exercises the production seam implementation (e.g. a genuine TLS-over-TCP handshake against a local server), kept bounded so it cannot hang and self-contained so it needs no external infrastructure, root, or non-loopback network.
+- **Feedback loop**: When something is too complex or too heavy to test, the required response is to extend the actors model with the missing capability (a new seam or actor), then test against it — iterating until the behavior is covered. Writing a slow, flaky, or hanging test instead is prohibited.
+- **Zero release cost**: Test actors and in-memory implementations MUST be gated out of release builds (e.g. behind a `test-actors` feature / `cfg(test)`), so they add no runtime cost or attack surface to shipped binaries. The seam traits and real implementations remain in production.
+
+**Rationale**: akon's core job — establishing VPN tunnels — is exactly the kind of behavior that is expensive, privileged, and disruptive to test against reality (it needs a server, root, and would drop the developer's own connectivity). Seam isolation plus in-memory actors make that behavior testable on every change, while a single bounded real end-to-end test guards against the divergence between emulation and production I/O (such as TLS read coalescing). This is what makes risky changes — above all, removing the `openconnect` dependency — safe to develop test-first and prove equivalent before shipping.
+
## Security Requirements
### Credential Isolation
@@ -130,6 +156,19 @@ FOLLOW-UP TODOS: None
- All PRs MUST pass: unit tests (pytest), type checking (mypy), linting (ruff), integration tests (keyring/file I/O).
- Security-critical modules MUST have dedicated test files: `test_auth.py`, `test_keyring_utils.py`, `test_password_generator.py`.
+### Test Methodology (Principle VI in practice)
+
+This section is the operational guide for satisfying Principle VI. It is the default way features touching the OS, network, processes, TLS, or privileged operations are built.
+
+- **Identify the seam first.** Before implementing anything that does real I/O, define the trait that abstracts it (read/write byte stream, OS effects, TUN device, connection backend). Logic depends on the trait, not on concrete sockets/commands.
+- **Pure layers stay pure.** Decompose protocols into pure, deterministic units (framing/codecs, state machines, parsers) that are testable with byte-exact vectors and need no I/O at all. Validate these against ground truth (e.g. the reference implementation's wire format) with explicit test vectors.
+- **Provide two implementations per seam.** A real one (production) and an in-memory actor (test). The actor reuses the real pure layers so tests exercise genuine code, not a re-mock of it.
+- **Drive tests with scenarios, not ad-hoc setup.** Compose real-world situations declaratively and assert on an ordered timeline of observable, backend-agnostic events. Reuse one scenario suite across implementations to prove equivalence.
+- **Bound everything.** Wrap handshakes/loops in `tokio::time::timeout`; ensure in-memory transports/channels report EOF on close and on drop. No unbounded `recv`/`read` without a deadline.
+- **Confirm on the real path.** Add at least one bounded, self-contained real end-to-end test (e.g. a local TLS server on loopback with a self-signed cert) for any replacement of a real integration. This is what catches emulation/production divergence (e.g. TLS coalescing post-`/myvpn` PPP bytes).
+- **Iterate the framework, not the workaround.** If a behavior can't be tested cleanly, extend the actors framework with the missing seam/actor and circle back — never settle for a slow, flaky, or hanging test.
+- **Gate test code out of releases.** Keep actors/in-memory impls behind a test feature/`cfg(test)`; ship only seams + real implementations.
+
### Documentation
- README MUST include: quick start, security best practices, troubleshooting, configuration examples.
@@ -159,7 +198,8 @@ All code reviews MUST verify:
- Test coverage for new code paths.
- Logging completeness for state changes.
- CLI interface consistency (exit codes, output format).
+- **Seam & test-actor compliance (Principle VI)**: heavy/real integrations are behind a seam with an in-memory actor; behavior is tested offline and deterministically; replacements include a bounded real end-to-end test; no test can hang; test-only code is gated out of release builds.
Complexity that violates modularity principles MUST be justified in commit messages or rejected.
-**Version**: 1.0.0 | **Ratified**: 2025-10-08 | **Last Amended**: 2025-10-08
+**Version**: 1.1.0 | **Ratified**: 2025-10-08 | **Last Amended**: 2026-06-21
diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md
index 778d2de..4cdd3d8 100644
--- a/.specify/templates/plan-template.md
+++ b/.specify/templates/plan-template.md
@@ -31,13 +31,14 @@
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
-Verify compliance with Auto-OpenConnect Constitution v1.0.0:
+Verify compliance with Auto-OpenConnect Constitution v1.1.0:
- [ ] **Security-First**: Are credentials stored only in GNOME Keyring? No plaintext secrets in code/config/logs?
- [ ] **Modular Architecture**: Is functionality decomposed into independent modules with clear boundaries?
- [ ] **Test-Driven Development**: Are tests written before implementation? Security modules >90% coverage?
- [ ] **Observability**: Are all state changes logged to systemd journal? No secrets in logs?
- [ ] **CLI-First Interface**: Is functionality accessible via CLI with composable outputs?
+- [ ] **Test Actors & Seam-Isolated Testing**: Are heavy/real integrations (OS, network, TLS, processes, root, time) behind a seam (trait) with an in-memory actor as ground truth? Is behavior tested offline, deterministically, and hang-proof (bounded waits, EOF-on-close/drop)? For any replacement of a real integration, is there a bounded real end-to-end test on the production seam? Is test-only code gated out of release builds?
**Security-Critical Changes** (require extra scrutiny):
- [ ] OAuth token handling
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..aa7660b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,65 @@
+# Changelog
+
+All notable changes to akon are documented here. This project adheres to
+[Semantic Versioning](https://semver.org/).
+
+## [2.0.0] — 2026-06-21
+
+### ⚠️ Breaking changes
+
+akon is now a **native, in-process F5 BIG-IP SSL VPN client** written in pure
+Rust. The `openconnect` delegation has been **removed entirely**. See
+`docs/adr/0002-remove-openconnect-native-f5-is-the-only-backend.md`.
+
+- **`openconnect` is no longer used or required.** akon no longer spawns
+ `openconnect` (or any child process) for the VPN protocol, and the
+ `openconnect`/`procps` package dependencies are gone.
+- **Runtime model changed: no `sudo`.** akon runs as your user (so the keyring
+ stays accessible). The only privilege needed is `CAP_NET_ADMIN` for the TUN
+ device and route setup, granted once as a **file capability**:
+
+ ```bash
+ sudo setcap cap_net_admin+ep "$(command -v akon)"
+ ```
+
+ Packaging post-install scripts and `make install` now do this automatically
+ (and remove the legacy `/etc/sudoers.d/akon` passwordless-sudo file). Requires
+ `libcap` (`setcap`): `apt install libcap2-bin` / `dnf install libcap`.
+- **Config: the `native_backend` flag is removed.** The native backend is always
+ used for `protocol = "f5"`. A leftover `native_backend = …` line is harmlessly
+ ignored.
+- **Protocol scope is F5.** Other openconnect protocol identifiers remain
+ parseable in config for forward-compatibility but are not implemented by the
+ native client.
+
+### Added
+
+- Native F5 client: F5 framing (encap + HDLC/FCS16), PPP (LCP/IPCP/IP6CP)
+ negotiation, HTTP auth + XML config, TLS transport, and orchestration behind a
+ backend-agnostic `VpnBackend` boundary — all validated test-first against an
+ in-memory test-actors framework and byte-exact wire vectors.
+- **In-process netlink** configuration of the TUN device, addresses, MTU, and
+ routes (no `ip`/`sysctl` child processes), enabling true rootless operation.
+- **Guaranteed host restore:** `akon vpn off` replays a persisted host-teardown
+ plan (tun, server-pin route, `rp_filter`, DNS) — idempotent, and works even if
+ the `vpn on` process was killed.
+- In-process health-checked reconnection (honors the `[reconnection]` config).
+- Containerized rootless validation and gated production sign-off tests.
+
+### Removed
+
+- `openconnect_backend`, `cli_connector`, `output_parser`, the openconnect
+ `process` module, `connection_event`, `system_effects`, and the spawned
+ reconnection daemon.
+- Dependencies: `which`, `bindgen`, `daemonize` (and `regex` from akon-core).
+- openconnect-specific error variants (`OpenConnectError`, `ProcessSpawnError`,
+ `TerminationError`, `ParseError`).
+
+### Migration
+
+1. Update akon (or `make install`).
+2. Ensure the capability is set: `getcap "$(command -v akon)"` should show
+ `cap_net_admin=ep`; if not, run the `setcap` command above.
+3. Remove any `native_backend = …` line from `~/.config/akon/config.toml`
+ (optional — it is ignored).
+4. Run akon **without** `sudo`: `akon vpn on`. You may uninstall `openconnect`.
diff --git a/Cargo.toml b/Cargo.toml
index 3d951f5..88b407e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,15 +4,15 @@ resolver = "2"
[package]
name = "akon"
-version = "1.2.3"
+version = "2.0.0"
edition = "2021"
authors = ["vcwild"]
-description = "A CLI tool for managing VPN connections with OpenConnect"
+description = "A native, dependency-free CLI tool for managing F5 BIG-IP SSL VPN connections"
license = "MIT"
repository = "https://github.com/vcwild/akon"
homepage = "https://github.com/vcwild/akon"
readme = "README.md"
-keywords = ["vpn", "openconnect", "cli", "networking"]
+keywords = ["vpn", "f5", "bigip", "cli", "networking"]
categories = ["command-line-utilities", "network-programming"]
[lints.rust]
@@ -24,10 +24,10 @@ maintainer = "vcwild"
copyright = "2025, vcwild"
license-file = ["LICENSE", "4"]
extended-description = """\
-akon is a command-line tool for managing VPN connections using OpenConnect.
-It provides an easy-to-use interface for connecting to VPN servers with support
-for automatic reconnection, health checks, and daemon mode operation."""
-depends = "openconnect, procps"
+akon is a native, in-process command-line F5 BIG-IP SSL VPN client (pure Rust,
+no openconnect). It connects, configures the tunnel via netlink, and supports
+automatic reconnection and health checks. It runs as the user; only TUN/route
+setup needs CAP_NET_ADMIN (granted via `setcap cap_net_admin+ep`)."""
section = "net"
priority = "optional"
assets = [
@@ -48,10 +48,6 @@ post_install_script = "rpm/post-install.sh"
pre_uninstall_script = "rpm/pre-uninstall.sh"
post_uninstall_script = "rpm/post-uninstall.sh"
-[package.metadata.generate-rpm.requires]
-openconnect = "*"
-procps-ng = "*"
-
[[bin]]
name = "akon"
path = "src/main.rs"
@@ -61,14 +57,12 @@ path = "src/main.rs"
clap.workspace = true
tracing.workspace = true
tracing-journald.workspace = true
-daemonize.workspace = true
nix.workspace = true
serde_json.workspace = true
libc.workspace = true
serde.workspace = true
tokio.workspace = true
# Additional dependencies
-which = "6.0"
chrono = "0.4"
colored = "2.1"
# Local crate
@@ -94,9 +88,6 @@ totp-lite = "2.0"
base32 = "0.4"
keyring = { version = "3.6", features = ["sync-secret-service"] }
-# Build and FFI
-bindgen = "0.69"
-daemonize = "0.5"
nix = { version = "0.27", features = ["signal", "process", "user"] }
serde_json = "1.0"
libc = "0.2"
diff --git a/Makefile b/Makefile
index 81cffc3..261ff67 100644
--- a/Makefile
+++ b/Makefile
@@ -4,56 +4,30 @@
all:
cargo build --release
-# Install release version with passwordless sudo setup
-# This configures everything needed to run akon without password prompts
+# Install release version and grant the CAP_NET_ADMIN file capability.
+# akon runs as your user (keyring intact); the only privilege it needs is
+# CAP_NET_ADMIN for the TUN device + netlink route setup. No openconnect, no
+# passwordless sudo.
install: all
@echo "Installing akon..."
sudo install -m 755 target/release/akon /usr/local/bin/akon
@echo "✓ Installed to /usr/local/bin/akon"
@echo ""
- @echo "Configuring passwordless sudo for openconnect, pkill, and kill..."
- @if ! command -v openconnect &> /dev/null; then \
- echo "ERROR: openconnect is not installed"; \
- echo "Please install it first:"; \
- echo " Ubuntu/Debian: sudo apt install openconnect"; \
- echo " RHEL/Fedora: sudo dnf install openconnect"; \
- exit 1; \
- fi
- @if ! command -v pkill &> /dev/null; then \
- echo "ERROR: pkill is not installed"; \
- echo "Please install procps package:"; \
- echo " Ubuntu/Debian: sudo apt install procps"; \
- echo " RHEL/Fedora: sudo dnf install procps-ng"; \
- exit 1; \
- fi
- @if [ ! -x /usr/bin/kill ] && [ ! -x /bin/kill ]; then \
- echo "ERROR: kill binary not found (expected at /usr/bin/kill or /bin/kill)"; \
- echo "Please ensure coreutils package providing kill is installed."; \
- exit 1; \
- fi
- @OPENCONNECT_PATH=$$(command -v openconnect); \
- PKILL_PATH=$$(command -v pkill); \
- if [ -x /usr/bin/kill ]; then \
- KILL_PATH=/usr/bin/kill; \
- else \
- KILL_PATH=/bin/kill; \
- fi; \
- SUDOERS_FILE="/etc/sudoers.d/akon"; \
- echo "# Allow $$USER to run openconnect, pkill, and kill without password for akon VPN" | sudo tee $$SUDOERS_FILE > /dev/null; \
- echo "$$USER ALL=(root) NOPASSWD: $$OPENCONNECT_PATH" | sudo tee -a $$SUDOERS_FILE > /dev/null; \
- echo "$$USER ALL=(root) NOPASSWD: $$PKILL_PATH" | sudo tee -a $$SUDOERS_FILE > /dev/null; \
- echo "$$USER ALL=(root) NOPASSWD: $$KILL_PATH" | sudo tee -a $$SUDOERS_FILE > /dev/null; \
- sudo chmod 0440 $$SUDOERS_FILE; \
- if sudo visudo -c -f $$SUDOERS_FILE 2>&1 | grep -q "parsed OK"; then \
- echo "✓ Passwordless sudo configured for openconnect, pkill, and kill"; \
- else \
- echo "ERROR: Invalid sudoers configuration"; \
- sudo rm -f $$SUDOERS_FILE; \
+ @echo "Removing any legacy passwordless-sudo config from older akon versions..."
+ @sudo rm -f /etc/sudoers.d/akon 2>/dev/null || true
+ @echo "Granting CAP_NET_ADMIN to the akon binary (setcap)..."
+ @if ! command -v setcap &> /dev/null; then \
+ echo "ERROR: 'setcap' not found. Install libcap:"; \
+ echo " Ubuntu/Debian: sudo apt install libcap2-bin"; \
+ echo " RHEL/Fedora: sudo dnf install libcap"; \
exit 1; \
fi
+ sudo setcap cap_net_admin+ep /usr/local/bin/akon
+ @echo "✓ Granted cap_net_admin+ep to /usr/local/bin/akon"
@echo ""
- @echo "Installation complete! You can now run:"
+ @echo "Installation complete! Run akon as your normal user (no sudo):"
@echo " akon setup"
+ @echo " akon vpn on"
# Install development version for debugging
install-dev:
@@ -84,31 +58,31 @@ deps:
if [ -z "$$SUDO" ]; then \
echo "Detected $$ID (Ubuntu/Debian)."; \
echo "Run as root or ensure 'sudo' is available and re-run:"; \
- echo " sudo apt-get update && sudo apt-get install -y openconnect libdbus-1-dev pkg-config"; \
+ echo " sudo apt-get update && sudo apt-get install -y libcap2-bin libdbus-1-dev pkg-config"; \
exit 0; \
fi; \
- echo "Installing openconnect, dbus dev, and pkg-config (apt)..."; \
- $$SUDO apt-get update && $$SUDO apt-get install -y openconnect libdbus-1-dev pkg-config; \
+ echo "Installing libcap (setcap), dbus dev, and pkg-config (apt)..."; \
+ $$SUDO apt-get update && $$SUDO apt-get install -y libcap2-bin libdbus-1-dev pkg-config; \
;; \
fedora|rhel|centos) \
if [ -z "$$SUDO" ]; then \
echo "Detected $$ID (Fedora/RHEL)."; \
echo "Run as root or ensure 'sudo' is available and re-run:"; \
- echo " sudo dnf install -y openconnect dbus-devel pkgconf-pkg-config"; \
+ echo " sudo dnf install -y libcap dbus-devel pkgconf-pkg-config"; \
exit 0; \
fi; \
- echo "Installing openconnect, dbus dev, and pkg-config (dnf/yum)..."; \
+ echo "Installing libcap (setcap), dbus dev, and pkg-config (dnf/yum)..."; \
if command -v dnf >/dev/null 2>&1; then \
- $$SUDO dnf install -y openconnect dbus-devel pkgconf-pkg-config; \
+ $$SUDO dnf install -y libcap dbus-devel pkgconf-pkg-config; \
else \
- $$SUDO yum install -y openconnect dbus-devel pkgconf-pkg-config; \
+ $$SUDO yum install -y libcap dbus-devel pkgconf-pkg-config; \
fi; \
;; \
*) \
echo "Could not detect a supported distro (ID=$$ID)."; \
echo "Please run one of the following commands manually depending on your distro:"; \
- echo " Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y openconnect libdbus-1-dev pkg-config"; \
- echo " Fedora/RHEL: sudo dnf install -y openconnect dbus-devel pkgconf-pkg-config"; \
+ echo " Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y libcap2-bin libdbus-1-dev pkg-config"; \
+ echo " Fedora/RHEL: sudo dnf install -y libcap dbus-devel pkgconf-pkg-config"; \
exit 0; \
;; \
esac'
diff --git a/README.md b/README.md
index 7e1b2f4..4048ed1 100644
--- a/README.md
+++ b/README.md
@@ -14,12 +14,19 @@ A CLI for managing VPN connections with automatic TOTP (Time-based One-Time Pass
## Features
+- **Native F5 VPN client**: a pure-Rust, in-process F5 BIG-IP SSL VPN
+ implementation (PPP-over-HTTPS). **No `openconnect`, no `sudo`-spawned child.**
+- **Rootless**: runs as your user (keyring intact); the only privilege needed is
+ `CAP_NET_ADMIN` for the TUN device + route setup, granted via a file capability
+ (`setcap cap_net_admin+ep`). TUN/address/route configuration is done in-process
+ via **netlink**.
- **Secure Credential Management**: Stores PIN and TOTP secret securely in GNOME Keyring
- **Automatic OTP Generation**: Generates TOTP tokens automatically during connection
-- **OpenConnect Integration**: Uses OpenConnect CLI for robust VPN connectivity (F5 protocol support)
-- **Automatic Reconnection**: Detects network interruptions and reconnects with exponential backoff
+- **Automatic Reconnection**: Detects network interruptions and reconnects with exponential backoff (supervised in-process)
+- **Guaranteed host restore**: `akon vpn off` reconciles every networking change
+ (tun, routes, rp_filter, DNS) from a persisted plan — even after a crash.
- **Health Monitoring**: Periodic health checks detect silent VPN failures
-- **Fast & Lightweight**: written in Rust and with minimal dependencies
+- **Fast & Lightweight**: written in Rust, dependency-light (no external VPN binary)
## Table of Contents
@@ -34,27 +41,30 @@ A CLI for managing VPN connections with automatic TOTP (Time-based One-Time Pass
## Requirements
-- **Operating System**: Linux (tested on Ubuntu/Debian, RHEL/Fedora)
-- **OpenConnect**: Version 9.x or later
+- **Operating System**: Linux (tested on Ubuntu/Debian, RHEL/Fedora). The VPN
+ data plane is Linux-only (TUN + netlink).
+- **`CAP_NET_ADMIN`**: needed to create the TUN device and configure routes.
+ Granted once as a **file capability** on the binary (no sudo at runtime):
```bash
- # Ubuntu/Debian
- sudo apt install openconnect
-
- # RHEL/Fedora
- sudo dnf install openconnect
-
- # Verify installation
- which openconnect
+ sudo setcap cap_net_admin+ep "$(command -v akon)"
+ # Requires libcap's setcap:
+ # Ubuntu/Debian: sudo apt install libcap2-bin
+ # RHEL/Fedora: sudo dnf install libcap
```
+ > Note: file capabilities do not elevate inside a user namespace
+ > (rootless-container dev environments) — those still need `sudo`/`--cap-add
+ > NET_ADMIN`. Normal bare-metal hosts get true rootless operation.
+
- **GNOME Keyring**: For secure credential storage
```bash
sudo apt install gnome-keyring libsecret-1-dev
```
-- **Root Privileges**: Required for TUN device creation (run with `sudo`)
+- **No `openconnect`**: akon is a self-contained native client and does **not**
+ use or require the `openconnect` binary.
## Installation
@@ -86,7 +96,7 @@ sudo dnf install ./akon-latest-1.x86_64.rpm
git clone https://github.com/vcwild/akon.git
cd akon
-# Build and install (sets up passwordless sudo for openconnect)
+# Build and install (grants the CAP_NET_ADMIN file capability)
make install
# Verify installation
@@ -97,8 +107,8 @@ akon --help
- Builds the release binary
- Installs to `/usr/local/bin/akon`
-- Configures passwordless sudo for openconnect
-- No password prompts when connecting to VPN!
+- Grants `cap_net_admin+ep` on the binary (so akon runs rootless, as your user)
+- Removes any legacy passwordless-sudo config from older akon versions
## Quick Start
@@ -128,14 +138,15 @@ These credentials are stored in:
akon vpn on
```
-**What happens:**
+**What happens (all in-process — `akon` *is* the VPN client):**
1. Loads config from `~/.config/akon/config.toml`
2. Retrieves PIN and TOTP secret from keyring
3. Generates current TOTP token
-4. Spawns OpenConnect with credentials
-5. Monitors connection progress
-6. Reports IP address when connected
+4. Connects natively over TLS (auth → config → PPP), configures the TUN device
+ and routes via netlink
+5. Carries the data plane and supervises health/reconnection in-process
+6. Reports IP address when connected (stays running until Ctrl-C or `akon vpn off`)
### 3. Check Status
@@ -157,8 +168,10 @@ akon vpn off
**Disconnect flow:**
-1. Sends SIGTERM for graceful shutdown (5s timeout)
-2. Falls back to SIGKILL if process doesn't respond
+1. Signals the running akon VPN process to stop (it drops the TUN and reverts in-process)
+2. Replays the persisted host-teardown plan to reconcile the host (removes the
+ tun, the VPN-server pin route, restores `rp_filter`, reverts DNS) — idempotent
+ and works even if the process was already killed
3. Cleans up state file
### 5. Manual OTP Generation
@@ -206,6 +219,93 @@ akon # Shows usage information
This feature is perfect for quick VPN connections - just type `akon` and go!
+### Native F5 backend (the only backend)
+
+akon is a **native, in-process F5 BIG-IP SSL VPN client** — there is no
+`openconnect` and no `native_backend` flag (the native path is always used for
+`protocol = "f5"`). It performs the full handshake over TLS
+(auth → XML config → `/myvpn` tunnel upgrade → PPP LCP/IPCP), configures the TUN
+device and routes **in-process via netlink**, applies DNS on `systemd-resolved`
+systems (Fedora/Ubuntu, with `resolvconf`/`resolv.conf` fallbacks), and
+supervises health/reconnection in-process (honoring the `[reconnection]`
+settings). It runs as your user with a `cap_net_admin+ep` file capability — no
+`sudo`. It is Linux-only.
+
+> Migrating from an older akon? Drop any `native_backend = ...` line from your
+> config (it is ignored now), ensure the binary has the capability
+> (`sudo setcap cap_net_admin+ep "$(command -v akon)"`, or just re-run
+> `make install`), and stop installing `openconnect`.
+
+#### Verifying against your own server (production sign-off)
+
+A deliberate, opt-in sign-off test (`tests/production_signoff_test.rs`) connects
+the native backend to **your own** configured F5 server using **your** local
+config and keyring credentials, reaches `Connected`, and disconnects
+immediately. No server, username, or network is hardcoded in akon — it reads
+everything from `~/.config/akon/config.toml` and the keyring at run time. It
+creates no TUN device and changes no routes/DNS, so it does not disrupt your
+connectivity. It is disabled by default and requires an explicit double opt-in:
+
+```bash
+AKON_SIGNOFF_PRODUCTION=1 \
+AKON_SIGNOFF_ACK=I_UNDERSTAND_THIS_HITS_PRODUCTION \
+cargo test --test production_signoff_test -- --nocapture
+```
+
+The control-plane sign-off above has been validated against a real production F5
+appliance (authenticated with PIN+OTP, completed the full handshake + PPP to
+network-up, assigned a tunnel IP, disconnected cleanly).
+
+#### Data-plane sign-off (proves traffic actually flows)
+
+A second, deeper gate (`tests/production_dataplane_signoff_test.rs`) opens a
+**real TUN device**, connects to your appliance, then routes **one** target you
+specify (`AKON_SOAK_PROBE_TARGET`, a host reachable only via the VPN) through the
+tunnel as a `/32` route and verifies it becomes reachable — proving user traffic
+traverses the native data plane. It **never installs a default route** (so it
+cannot hijack your connectivity), removes the route and tears down the TUN on
+every exit (including failures), and is bounded. It needs root (`CAP_NET_ADMIN`)
+and is triple-gated:
+
+Use the helper, which builds as your user, generates the PIN+OTP as your user
+(`akon get-password`), then runs the test binary, passing the password via
+`AKON_SOAK_PASSWORD` (never printed):
+
+```bash
+AKON_SOAK_PROBE_TARGET=intranet.example.com ./test-support/run-dataplane-signoff.sh
+```
+
+> Rootless runtime is fully implemented: with `setcap cap_net_admin+ep` on the
+> binary, akon configures the TUN and routes in-process via netlink as your user
+> — no `sudo`. The containerized proof is `test-support/run-rootless-validation.sh`
+> (runs the data plane as a non-root user inside a container). The soak still
+> uses elevation only where your environment requires it for `/dev/net/tun`.
+
+The probe target may be **VPN-only**: if its name doesn't resolve before the
+tunnel is up, the soak routes the negotiated VPN DNS server through the tunnel
+and resolves the name **through the tunnel** (which itself proves the data plane
+carries traffic). You can also pass an **IP literal**
+(`AKON_SOAK_PROBE_TARGET=10.10.x.y:443`) to skip DNS entirely. The whole soak is
+bounded by a hard 30s deadline and tears down the TUN + all routes on every exit.
+
+The probe target accepts a bare host, `host:port`, or a full URL (port defaults
+to 443). Equivalent manual form (build first, then sudo the binary):
+
+```bash
+BIN=$(cargo test --test production_dataplane_signoff_test --no-run \
+ --message-format=json | sed -n 's/.*"executable":"\([^"]*production_dataplane_signoff_test[^"]*\)".*/\1/p' | tail -1)
+sudo -E AKON_F5_DEBUG=1 \
+ AKON_SIGNOFF_PRODUCTION=1 \
+ AKON_SIGNOFF_ACK=I_UNDERSTAND_THIS_HITS_PRODUCTION \
+ AKON_SOAK_PROBE_TARGET=intranet.example.com \
+ "$BIN" --nocapture
+```
+
+The route/teardown mechanics are rehearsed locally on a real TUN by the gated
+test `native_f5_real_tun_tests` (`sudo -E AKON_RUN_TUN_TESTS=1 cargo test
+-p akon-core --features test-actors --test native_f5_real_tun_tests`), so the
+production run only adds the live appliance.
+
### Automatic Reconnection
akon automatically detects network interruptions and reconnects your VPN with intelligent retry logic.
@@ -243,14 +343,24 @@ The name "akon" is a playful triple entendre:
## Architecture
-akon uses a **CLI process delegation** architecture:
-
-- Spawns OpenConnect as a child process
-- Manages process lifecycle (spawn → monitor → terminate)
-- Parses output in real-time for connection events
-- Provides clean async API using Tokio
-
-This design eliminates FFI complexity while maintaining full OpenConnect functionality.
+akon is a **native, in-process F5 VPN client** (no external process):
+
+- Connects to the F5 appliance over TLS and runs the full protocol in-process
+ (HTTP auth → XML config → `/myvpn` tunnel upgrade → PPP LCP/IPCP), all behind a
+ `Transport` seam.
+- Carries the data plane itself: a bidirectional pump moving IP packets between
+ a real Linux TUN device and the F5/PPP framing.
+- Configures the interface, addresses, and routes **in-process via netlink**
+ (rootless under a `cap_net_admin+ep` file capability); applies DNS via the
+ system resolver.
+- Records every host mutation in a persisted teardown plan so `akon vpn off`
+ always restores the host.
+- Built test-first against an in-memory test-actors framework (the same
+ `VpnBackend` boundary is exercised by a `SimulatedBackend` oracle), with
+ byte-exact protocol vectors and netns/container/production sign-off tests.
+
+This design removes the external `openconnect` dependency, the `sudo`-spawned
+child, and the FFI of earlier versions.
### How It Works
@@ -262,11 +372,11 @@ flowchart TB
Config --> Keyring[🔐 Retrieve Credentials GNOME Keyring]
Keyring -->|PIN + TOTP Secret| TOTP[Generate TOTP Token Time-based OTP]
- TOTP -->|PIN+OTP| Connector[CLI Connector Process Manager]
+ TOTP -->|PIN+OTP| Connector[Native F5 Backend in-process client]
- Connector -->|spawn sudo openconnect| OC[🌐 OpenConnect Process VPN Tunnel]
+ Connector -->|TLS: auth → config → PPP| OC[🌐 TUN device netlink routes]
- OC -->|stdout/stderr| Parser[Output Parser Regex Matching]
+ OC -->|LifecycleEvents| Parser[Data-plane pump TUN ↔ F5/PPP framing]
Parser -->|Connection Events| Monitor[Connection Monitor State Machine]
Monitor -->|Connected Event| State[Update State /tmp/akon_vpn_state.json]
@@ -284,7 +394,7 @@ flowchart TB
Monitor -.->|NetworkManager D-Bus| NM[📶 Network Events WiFi/Ethernet Changes]
NM -.->|suspend/resume WiFi change| Reconnect
- Reconnect -->|backoff: 5s→10s→20s→40s→60s| Connector
+ Reconnect -->|backoff: 5s→10s→20s→40s→60s, in-process| Connector
style User fill:#34495e,stroke:#2c3e50,stroke-width:3px,color:#fff
style CLI fill:#3498db,stroke:#2980b9,stroke-width:3px,color:#fff
@@ -330,11 +440,12 @@ flowchart TB
1. **[CLI Layer](./src/cli)**: Command handlers for `setup`, `vpn on/off/status`, `get-password`
2. **[Config Management](./akon-core/src/config)**: TOML configuration with secure credential storage
3. **[Authentication](./akon-core/src/auth)**: TOTP generation, keyring integration, password assembly
-4. **[VPN Connector](./akon-core/src/vpn/cli_connector.rs)**: OpenConnect process lifecycle management
-5. **[Output Parser](./akon-core/src/vpn/output_parser.rs)**: Real-time parsing of OpenConnect output
-6. **[Health Monitoring](./akon-core/src/vpn/health_check.rs)**: Periodic endpoint checks for silent failures
-7. **[Reconnection Manager](./akon-core/src/vpn/reconnection.rs)**: Exponential backoff retry logic
-8. **[State Management](./akon-core/src/vpn/state.rs)**: Persistent connection state tracking
+4. **[Native F5 backend](./akon-core/src/vpn/f5)**: pure-Rust F5 client — framing, PPP, auth, config, HTTP, TLS transport, and orchestration (`backend.rs`)
+5. **[netlink](./akon-core/src/vpn/f5/netlink.rs)** & **[TUN](./akon-core/src/vpn/f5/tun.rs)**: in-process link/address/route setup and the real TUN device
+6. **[Host teardown](./akon-core/src/vpn/f5/teardown.rs)**: persisted plan + idempotent reconciler used by `vpn off`
+7. **[Health Monitoring](./akon-core/src/vpn/health_check.rs)**: Periodic endpoint checks for silent failures
+8. **[Reconnection](./akon-core/src/vpn/reconnection.rs)**: Exponential backoff retry logic (supervised in-process)
+9. **[State Management](./akon-core/src/vpn/state.rs)**: Persistent connection state tracking
### Logging
@@ -357,9 +468,14 @@ akon/
│ │ ├── auth/ # OTP, keyring, password generation
│ │ ├── config/ # TOML configuration
│ │ ├── vpn/ # VPN connection management
-│ │ │ ├── cli_connector.rs # OpenConnect process manager
-│ │ │ ├── output_parser.rs # Output parsing with regex
-│ │ │ └── connection_event.rs # Event types
+│ │ │ ├── backend.rs # VpnBackend boundary + lifecycle events
+│ │ │ ├── transport.rs # Transport / TunDevice / DnsApplier seams
+│ │ │ ├── f5/ # Native F5 backend
+│ │ │ │ ├── backend.rs # Orchestration (impl VpnBackend)
+│ │ │ │ ├── framing.rs ppp.rs auth.rs config.rs http.rs # protocol layers
+│ │ │ │ ├── tls_transport.rs netlink.rs tun.rs dns.rs # real I/O adapters
+│ │ │ │ └── teardown.rs # host-teardown plan + reconciler
+│ │ │ └── testkit/ # in-memory actors + SimulatedBackend (test-only)
│ │ └── error.rs # Error types
│ └── tests/ # Unit tests
├── src/ # CLI application
diff --git a/akon-core/Cargo.toml b/akon-core/Cargo.toml
index 68439f9..b76c6e6 100644
--- a/akon-core/Cargo.toml
+++ b/akon-core/Cargo.toml
@@ -1,25 +1,47 @@
[package]
edition = "2021"
name = "akon-core"
-version = "1.2.3"
+version = "2.0.0"
[features]
default = []
# Enable the mock keyring implementation and its test-only dependencies
mock-keyring = ["lazy_static"]
+# Enable the test actors framework (simulated backend + actors). Auto-available
+# under `cfg(test)`; gated out of release builds so it adds no runtime cost.
+test-actors = ["dep:rcgen", "dep:rustls-pemfile"]
[lints.rust]
dead_code = "deny"
+# Standalone F5 test server used by the Podman real-host integration test.
+# Only built with the `test-actors` feature; absent from release builds.
+[[bin]]
+name = "f5_test_server"
+required-features = ["test-actors"]
+
+# Standalone native-F5 client run inside Fedora/Ubuntu containers to validate
+# the backend + distro DNS application. Only built with `test-actors`.
+[[bin]]
+name = "f5_test_client"
+required-features = ["test-actors"]
+
+# Containerized data-plane round-trip probe (reproduces the local-delivery
+# loop with the real LinuxTun). Only built with `test-actors`.
+[[bin]]
+name = "f5_dataplane_probe"
+required-features = ["test-actors"]
+
[dependencies]
# Workspace dependencies
anyhow.workspace = true
+async-trait = "0.1"
base32.workspace = true
+libc = "0.2"
chrono = "0.4"
data-encoding = "2.9.0"
keyring.workspace = true
nix.workspace = true
-regex = "1.10"
secrecy.workspace = true
serde.workspace = true
sha1 = "0.10.6"
@@ -38,6 +60,18 @@ reqwest = {version = "0.12", default-features = false, features = ["rustls-tls"]
url = "2.5"
zbus = "4.0"
+# Native TLS transport for the native F5 backend (real TLS-over-TCP). These are
+# already in the dependency tree via reqwest's rustls-tls; declared directly so
+# the native transport can use them.
+rustls = {version = "0.23", default-features = false, features = ["ring", "std", "tls12"]}
+tokio-rustls = {version = "0.26", default-features = false, features = ["ring", "tls12"]}
+webpki-roots = "1.0"
+
+# Optional: only compiled for the `test-actors` feature (the containerized F5
+# test server binary). Not present in release builds.
+rcgen = {version = "0.13", optional = true}
+rustls-pemfile = {version = "2.0", optional = true}
+
[dev-dependencies]
cargo-tarpaulin = "0.27"
criterion = "0.5"
@@ -47,3 +81,17 @@ serde_json = "1.0"
tempfile = "3.0"
tokio-test = "0.4"
wiremock = "0.6"
+
+# The containerized/real-TLS integration tests require the test-actors feature
+# (which enables rcgen + rustls-pemfile + the in-memory actors).
+[[test]]
+name = "native_f5_real_tls_tests"
+required-features = ["test-actors"]
+
+[[test]]
+name = "native_f5_podman_tests"
+required-features = ["test-actors"]
+
+[[test]]
+name = "native_f5_real_tun_tests"
+required-features = ["test-actors"]
diff --git a/akon-core/src/bin/f5_dataplane_probe.rs b/akon-core/src/bin/f5_dataplane_probe.rs
new file mode 100644
index 0000000..882b556
--- /dev/null
+++ b/akon-core/src/bin/f5_dataplane_probe.rs
@@ -0,0 +1,246 @@
+//! Containerized data-plane round-trip probe.
+//!
+//! Runs the REAL native F5 data plane inside a container/netns to reproduce the
+//! production "reply loops / not delivered locally" symptom deterministically:
+//!
+//! 1. Spawns an in-process fake F5 server (`F5ServerActor`) that completes the
+//! handshake and, in the data phase, **echoes IP packets with src/dst
+//! swapped** (a faithful echo responder).
+//! 2. Brings up `NativeF5Backend` with a **real `LinuxTun`** over an in-memory
+//! transport to that server, so the actual TUN + routing code runs.
+//! 3. Binds a UDP socket to the assigned tunnel IP, sends a datagram to a target
+//! IP that is routed through the tunnel, and checks the **echo comes back to
+//! the local socket**.
+//!
+//! Prints `RESULT: ok` (round-trip delivered) or `RESULT: fail ` and exits
+//! accordingly. Needs `CAP_NET_ADMIN` (run in a container with --cap-add
+//! NET_ADMIN --device /dev/net/tun, or as root). Only built with `test-actors`.
+
+use std::time::Duration;
+
+use akon_core::vpn::backend::{Credentials, LifecycleEvent, VpnBackend};
+use akon_core::vpn::f5::dns::NoopDns;
+use akon_core::vpn::f5::tun::LinuxTun;
+use akon_core::vpn::f5::NativeF5Backend;
+use akon_core::vpn::testkit::{F5ServerActor, F5ServerScript, MemoryTransport};
+
+#[tokio::main]
+async fn main() {
+ match run().await {
+ Ok(()) => {
+ println!("RESULT: ok");
+ std::process::exit(0);
+ }
+ Err(e) => {
+ println!("RESULT: fail {e}");
+ std::process::exit(1);
+ }
+ }
+}
+
+async fn run() -> Result<(), String> {
+ // HOST-SAFETY GUARD: this probe creates a real TUN and installs full-tunnel
+ // routes, which would hijack the host's networking. Refuse to run unless we
+ // are in an ISOLATED network namespace (not the host's init netns), so it can
+ // never disrupt a developer's or production host. Run it via `unshare -rn`
+ // (the netns regression test does this) or inside a container.
+ require_isolated_netns()?;
+
+ // Open a real TUN early to fail fast without privileges.
+ let tun = LinuxTun::open("").map_err(|e| format!("open TUN (need CAP_NET_ADMIN): {e}"))?;
+
+ // In-memory transport pair: one end drives the fake F5 server (echo mode),
+ // the other is the backend's tunnel transport.
+ let (client, mut server) = MemoryTransport::pair();
+ let script = F5ServerScript {
+ // assigned tunnel IP for the client
+ assigned_ip: [10, 10, 99, 2],
+ ..F5ServerScript::default()
+ };
+ tokio::spawn(async move {
+ F5ServerActor::new(script).run(&mut server).await;
+ });
+
+ let mut backend = NativeF5Backend::with_parts(
+ Box::new(client),
+ Box::new(tun),
+ Box::new(NoopDns),
+ "f5.local",
+ );
+
+ let mut rx = backend
+ .connect(Credentials::new("probe", "1234567890"))
+ .map_err(|e| format!("connect start: {e}"))?;
+
+ let mut tun_ip = None;
+ while let Ok(Some(ev)) = tokio::time::timeout(Duration::from_secs(15), rx.recv()).await {
+ if let LifecycleEvent::Connected { ip, .. } = ev {
+ tun_ip = Some(ip);
+ break;
+ }
+ if matches!(ev, LifecycleEvent::Failed { .. }) {
+ return Err(format!("connect failed: {ev:?}"));
+ }
+ }
+ let tun_ip = tun_ip.ok_or("never reached Connected")?;
+ eprintln!("probe: connected, tunnel ip {tun_ip}");
+
+ // Route a target IP through the tunnel. The echo server swaps src/dst, so a
+ // packet we send to `target` returns as `target -> tun_ip`, which must be
+ // delivered to our local socket.
+ use akon_core::vpn::f5::netlink::{if_nametoindex, NetlinkSocket};
+ use std::net::{Ipv4Addr, SocketAddrV4};
+ let target: Ipv4Addr = "10.10.99.50".parse().expect("valid ipv4");
+ let dst = SocketAddrV4::new(target, 7777);
+ let dev = "tun0";
+ // Route the probe target through the tun via NETLINK (not a child `ip`), so
+ // the probe itself is rootless-capable under a `cap_net_admin+ep` file
+ // capability — a spawned `ip` would not inherit the capability.
+ let ifindex = if_nametoindex(dev).map_err(|e| format!("if_nametoindex({dev}): {e}"))?;
+ let mut nl = NetlinkSocket::open().map_err(|e| format!("netlink open: {e}"))?;
+ nl.route_add_dev(target, 32, ifindex, true)
+ .map_err(|e| format!("failed to route {target}/32 via {dev}: {e}"))?;
+ eprintln!("probe: routed {target}/32 via {dev} (netlink)");
+
+ // Bind a UDP socket to the tunnel IP.
+ let bind_addr = SocketAddrV4::new(tun_ip_v4(tun_ip)?, 0);
+ let sock = tokio::net::UdpSocket::bind(bind_addr)
+ .await
+ .map_err(|e| format!("bind udp on {bind_addr}: {e}"))?;
+ let local = sock.local_addr().map_err(|e| format!("local_addr: {e}"))?;
+ eprintln!("probe: udp socket bound to {local}");
+ let payload = b"AKON_DATAPLANE_PROBE";
+
+ // Send a few datagrams (one may be lost while the tun settles) and wait for
+ // the swapped echo to be delivered back to our local socket.
+ let mut buf = [0u8; 256];
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(6);
+ let mut next_send = tokio::time::Instant::now();
+ loop {
+ if tokio::time::Instant::now() >= deadline {
+ let _ = backend.disconnect();
+ return Err(
+ "no echo received through tunnel within 6s (data-plane round-trip failed)".into(),
+ );
+ }
+ if tokio::time::Instant::now() >= next_send {
+ match sock.send_to(payload, dst).await {
+ Ok(n) => eprintln!("probe: sent {n} bytes to {dst} via tunnel"),
+ Err(e) => eprintln!("probe: send error: {e}"),
+ }
+ next_send = tokio::time::Instant::now() + Duration::from_millis(750);
+ }
+ match tokio::time::timeout(Duration::from_millis(500), sock.recv_from(&mut buf)).await {
+ Ok(Ok((n, from))) => {
+ eprintln!("probe: received {n} bytes from {from}");
+ if &buf[..n] != payload {
+ let _ = backend.disconnect();
+ return Err("echo payload mismatch".into());
+ }
+ // Round-trip proven. Now exercise the teardown reconciler: it
+ // must remove every host mutation (interface + routes) so a real
+ // host can't be left black-holed after `akon vpn off`.
+ verify_teardown(&mut backend).await?;
+ return Ok(());
+ }
+ Ok(Err(e)) => eprintln!("probe: recv error: {e}"),
+ Err(_) => {} // timeout slice; loop and maybe re-send
+ }
+ }
+}
+
+/// Capture the backend's teardown plan, disconnect, run the host reconciler, and
+/// assert the interface and the default-override routes are gone — proving
+/// `akon vpn off` restores host networking. Prints `TEARDOWN: ok` on success.
+async fn verify_teardown(backend: &mut akon_core::vpn::f5::NativeF5Backend) -> Result<(), String> {
+ use akon_core::vpn::backend::VpnBackend;
+ use akon_core::vpn::f5::teardown::teardown_host;
+
+ let plan = backend.teardown_plan();
+ let dev = plan.device.clone().ok_or("teardown plan has no device")?;
+ eprintln!("probe: teardown plan = {plan:?}");
+
+ let _ = backend.disconnect();
+ tokio::time::sleep(Duration::from_millis(300)).await;
+
+ let report = teardown_host(&plan);
+ for a in &report.actions {
+ eprintln!("probe: teardown action: {a}");
+ }
+
+ // The interface must be gone.
+ let dev_present = std::process::Command::new("ip")
+ .args(["link", "show", &dev])
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ .map(|s| s.success())
+ .unwrap_or(false);
+ if dev_present {
+ return Err(format!("interface {dev} still present after teardown"));
+ }
+
+ // The default-override routes must be gone (they die with the interface).
+ let routes = std::process::Command::new("ip")
+ .args(["route", "show"])
+ .output()
+ .map_err(|e| format!("ip route show: {e}"))?;
+ let routes = String::from_utf8_lossy(&routes.stdout);
+ if routes.contains(&dev) {
+ return Err(format!("routes via {dev} still present after teardown"));
+ }
+
+ eprintln!("TEARDOWN: ok");
+ Ok(())
+}
+
+/// Refuse to run unless the caller has explicitly placed us in an isolated
+/// network namespace (or container) AND verified the host is unreachable. This
+/// probe creates a real TUN and installs **full-tunnel** routes, so running it
+/// in the host netns would hijack the operator's networking. Rather than rely on
+/// fragile auto-detection (which breaks under user namespaces), we require an
+/// explicit handshake token that ONLY the isolation wrapper sets:
+///
+/// `AKON_PROBE_ISOLATED=1`
+///
+/// As an additional safety net, we also confirm there is **no real default
+/// route off a physical interface** — i.e. the netns is the throwaway kind with
+/// only loopback/tun. If a real uplink default is visible, we refuse even with
+/// the token set, so the probe can never black-hole a host's connectivity.
+fn require_isolated_netns() -> Result<(), String> {
+ if std::env::var("AKON_PROBE_ISOLATED").as_deref() != Ok("1") {
+ return Err(
+ "refusing to run: this probe creates a real TUN + full-tunnel \
+ routes and must run ONLY inside an isolated network namespace \
+ or container. The isolation wrapper must set \
+ AKON_PROBE_ISOLATED=1 (see native_f5_netns_roundtrip_tests / \
+ the container harness). Never run it directly on a host."
+ .to_string(),
+ );
+ }
+ // Belt-and-suspenders: ensure no real uplink default route exists in this
+ // namespace (a throwaway netns has only lo / a lo-default, not a physical
+ // uplink). This blocks accidentally setting the token on a real host.
+ if let Ok(mut nl) = akon_core::vpn::f5::netlink::NetlinkSocket::open() {
+ if let Ok(Some((gw, oif))) = nl.default_route() {
+ let name = akon_core::vpn::f5::netlink::if_indextoname(oif).unwrap_or_default();
+ // A real uplink default has a non-loopback device and a real gateway.
+ if !name.is_empty() && name != "lo" && !gw.is_unspecified() {
+ return Err(format!(
+ "refusing to run: a real default route (via {gw} dev {name}) is \
+ visible — this looks like a real host, not an isolated netns. \
+ Run inside `unshare -rn` (loopback only) or a container."
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Extract the IPv4 form of the assigned tunnel address.
+fn tun_ip_v4(ip: std::net::IpAddr) -> Result {
+ match ip {
+ std::net::IpAddr::V4(v4) => Ok(v4),
+ std::net::IpAddr::V6(_) => Err("tunnel IP is not IPv4".into()),
+ }
+}
diff --git a/akon-core/src/bin/f5_test_client.rs b/akon-core/src/bin/f5_test_client.rs
new file mode 100644
index 0000000..9352be0
--- /dev/null
+++ b/akon-core/src/bin/f5_test_client.rs
@@ -0,0 +1,122 @@
+//! Standalone native-F5 client — run **inside** a Fedora/Ubuntu container to
+//! validate the native backend (and especially the distro-specific DNS
+//! application) against real distro userland, with no host side effects.
+//!
+//! It:
+//! 1. Connects the native F5 backend to the F5 test server over real TLS
+//! (trusting the server cert at `AKON_F5_CA`), driving to `Connected`.
+//! 2. Exercises the real [`akon_core::vpn::f5::dns::SystemDnsApplier`] against
+//! the container's resolver (systemd-resolved/`resolvectl` on Fedora/Ubuntu,
+//! with `resolvconf`/`/etc/resolv.conf` fallbacks), printing the detected
+//! backend and applying a sample DNS config to a dummy interface.
+//!
+//! Prints `RESULT: ok backend=` on success and exits 0; prints
+//! `RESULT: fail ...` and exits non-zero otherwise. Only compiled with the
+//! `test-actors` feature.
+//!
+//! Env vars:
+//! - `AKON_F5_HOST` — F5 server host (default `f5server`)
+//! - `AKON_F5_PORT` — F5 server TLS port (default `8443`)
+//! - `AKON_F5_CA` — path to the server cert PEM to trust (default `/certs/server.pem`)
+//! - `AKON_DNS_IFACE`— interface name to apply DNS to (default `lo`)
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use akon_core::vpn::backend::{Credentials, LifecycleEvent, VpnBackend};
+use akon_core::vpn::f5::dns::{DnsApplier, SystemDnsApplier};
+use akon_core::vpn::f5::tls_transport::TlsTransport;
+use akon_core::vpn::f5::NativeF5Backend;
+use akon_core::vpn::transport::TunConfig;
+use tokio_rustls::rustls::{ClientConfig, RootCertStore};
+
+fn env_or(key: &str, default: &str) -> String {
+ std::env::var(key).unwrap_or_else(|_| default.to_string())
+}
+
+fn client_config_trusting(ca_path: &str) -> Result, String> {
+ let pem = std::fs::read(ca_path).map_err(|e| format!("read CA {ca_path}: {e}"))?;
+ let mut reader = std::io::BufReader::new(&pem[..]);
+ let mut roots = RootCertStore::empty();
+ for item in rustls_pemfile::certs(&mut reader).flatten() {
+ let _ = roots.add(item);
+ }
+ Ok(Arc::new(
+ ClientConfig::builder()
+ .with_root_certificates(roots)
+ .with_no_client_auth(),
+ ))
+}
+
+#[tokio::main]
+async fn main() {
+ match run().await {
+ Ok(backend) => {
+ println!("RESULT: ok backend={backend:?}");
+ std::process::exit(0);
+ }
+ Err(e) => {
+ println!("RESULT: fail {e}");
+ std::process::exit(1);
+ }
+ }
+}
+
+async fn run() -> Result {
+ let host = env_or("AKON_F5_HOST", "f5server");
+ let port: u16 = env_or("AKON_F5_PORT", "8443").parse().unwrap_or(8443);
+ let ca = env_or("AKON_F5_CA", "/certs/server.pem");
+ let iface = env_or("AKON_DNS_IFACE", "lo");
+
+ // --- 1. Connect over real TLS to the F5 server and reach Connected ---
+ let config = client_config_trusting(&ca)?;
+ let transport = TlsTransport::connect_with_config(&host, port, config)
+ .await
+ .map_err(|e| format!("TLS connect {host}:{port}: {e}"))?;
+
+ let mut backend = NativeF5Backend::with_transport(Box::new(transport), host.clone());
+ let mut rx = backend
+ .connect(Credentials::new("testuser", "1234567890"))
+ .map_err(|e| format!("connect start: {e}"))?;
+
+ let mut connected = false;
+ while let Ok(Some(ev)) = tokio::time::timeout(Duration::from_secs(20), rx.recv()).await {
+ match ev {
+ LifecycleEvent::Connected { ip, .. } => {
+ eprintln!("client: connected, assigned ip {ip}");
+ connected = true;
+ break;
+ }
+ LifecycleEvent::Failed { kind, detail } => {
+ return Err(format!("connection failed: {kind:?}: {detail}"));
+ }
+ _ => {}
+ }
+ }
+ if !connected {
+ return Err("did not reach Connected".to_string());
+ }
+
+ // --- 2. Exercise the real distro DNS applier ---
+ let mut dns = SystemDnsApplier::detect();
+ let backend_kind = dns.backend();
+ eprintln!("client: detected DNS backend = {backend_kind:?}");
+
+ let dns_config = TunConfig {
+ ipv4: Some("10.20.30.40".into()),
+ mtu: Some(1400),
+ dns: vec!["10.20.30.53".into()],
+ domains: vec!["corp.example.com".into()],
+ routes: vec![],
+ ..Default::default()
+ };
+
+ dns.apply(&iface, &dns_config)
+ .map_err(|e| format!("dns apply on {iface}: {e}"))?;
+ eprintln!("client: DNS applied on {iface}");
+
+ // Best-effort revert so we leave the container resolver as we found it.
+ let _ = dns.revert(&iface);
+
+ Ok(backend_kind)
+}
diff --git a/akon-core/src/bin/f5_test_server.rs b/akon-core/src/bin/f5_test_server.rs
new file mode 100644
index 0000000..d69eb50
--- /dev/null
+++ b/akon-core/src/bin/f5_test_server.rs
@@ -0,0 +1,123 @@
+//! Standalone F5 test server (TLS) — the workload run inside a Podman container
+//! for real-host integration testing of the native F5 backend.
+//!
+//! It generates a self-signed certificate (SAN from `AKON_F5_SAN`, default
+//! `127.0.0.1`), writes the certificate PEM to `AKON_F5_CERT_OUT` (so the client
+//! can trust it), listens for TLS connections on `AKON_F5_LISTEN`
+//! (default `0.0.0.0:8443`), and serves the real F5 protocol via
+//! [`akon_core::vpn::testkit::f5_server_actor::F5ServerActor`] over each
+//! accepted TLS stream.
+//!
+//! This binary is only compiled with the `test-actors` feature, so it never
+//! ships in release builds.
+//!
+//! Env vars:
+//! - `AKON_F5_LISTEN` — bind address (default `0.0.0.0:8443`)
+//! - `AKON_F5_SAN` — certificate SAN, an IP or DNS name (default `127.0.0.1`)
+//! - `AKON_F5_CERT_OUT` — path to write the server cert PEM (default `/certs/server.pem`)
+//! - `AKON_F5_ASSIGNED_IP` — IPv4 the server assigns the client (default `10.20.30.40`)
+
+use std::net::IpAddr;
+use std::sync::Arc;
+
+use akon_core::vpn::testkit::f5_server_actor::{F5ServerActor, F5ServerScript};
+use akon_core::vpn::transport::Transport;
+use async_trait::async_trait;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
+use tokio_rustls::rustls::ServerConfig;
+use tokio_rustls::TlsAcceptor;
+
+/// Adapter so the server side of a real TLS stream satisfies `Transport`.
+struct ServerTlsTransport {
+ stream: tokio_rustls::server::TlsStream,
+}
+
+#[async_trait]
+impl Transport for ServerTlsTransport {
+ async fn send(&mut self, data: &[u8]) -> std::io::Result<()> {
+ self.stream.write_all(data).await?;
+ self.stream.flush().await
+ }
+ async fn recv(&mut self, buf: &mut [u8]) -> std::io::Result {
+ self.stream.read(buf).await
+ }
+ async fn close(&mut self) -> std::io::Result<()> {
+ self.stream.shutdown().await
+ }
+}
+
+fn env_or(key: &str, default: &str) -> String {
+ std::env::var(key).unwrap_or_else(|_| default.to_string())
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let listen = env_or("AKON_F5_LISTEN", "0.0.0.0:8443");
+ let san = env_or("AKON_F5_SAN", "127.0.0.1");
+ let cert_out = env_or("AKON_F5_CERT_OUT", "/certs/server.pem");
+ let assigned_ip = env_or("AKON_F5_ASSIGNED_IP", "10.20.30.40");
+
+ // Generate a self-signed certificate with the requested SAN(s) (comma-
+ // separated; each entry an IP or DNS name). Always include loopback so the
+ // host can also reach the published port.
+ let mut params = rcgen::CertificateParams::new(Vec::::new())?;
+ let mut sans: Vec = san.split(',').map(|s| s.trim().to_string()).collect();
+ if !sans.iter().any(|s| s == "127.0.0.1") {
+ sans.push("127.0.0.1".to_string());
+ }
+ for entry in &sans {
+ match entry.parse::() {
+ Ok(ip) => params.subject_alt_names.push(rcgen::SanType::IpAddress(ip)),
+ Err(_) => params
+ .subject_alt_names
+ .push(rcgen::SanType::DnsName(entry.clone().try_into()?)),
+ }
+ }
+ let key_pair = rcgen::KeyPair::generate()?;
+ let cert = params.self_signed(&key_pair)?;
+
+ // Write the cert PEM so the client can trust it.
+ let cert_pem = cert.pem();
+ if let Some(parent) = std::path::Path::new(&cert_out).parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ std::fs::write(&cert_out, &cert_pem)?;
+ eprintln!("f5_test_server: wrote cert to {cert_out}");
+
+ let cert_der = CertificateDer::from(cert.der().to_vec());
+ let key_der = PrivateKeyDer::try_from(key_pair.serialize_der())?;
+ let server_config = ServerConfig::builder()
+ .with_no_client_auth()
+ .with_single_cert(vec![cert_der], key_der)?;
+ let acceptor = TlsAcceptor::from(Arc::new(server_config));
+
+ let assigned: [u8; 4] = {
+ let ip: std::net::Ipv4Addr = assigned_ip.parse()?;
+ ip.octets()
+ };
+ let script = F5ServerScript {
+ assigned_ip: assigned,
+ ..F5ServerScript::default()
+ };
+
+ let listener = TcpListener::bind(&listen).await?;
+ eprintln!("f5_test_server: listening on {listen} (SAN={san})");
+
+ loop {
+ let (tcp, peer) = listener.accept().await?;
+ let acceptor = acceptor.clone();
+ let script = script.clone();
+ tokio::spawn(async move {
+ match acceptor.accept(tcp).await {
+ Ok(tls) => {
+ eprintln!("f5_test_server: TLS session from {peer}");
+ let mut transport = ServerTlsTransport { stream: tls };
+ F5ServerActor::new(script).run(&mut transport).await;
+ }
+ Err(e) => eprintln!("f5_test_server: TLS handshake failed: {e}"),
+ }
+ });
+ }
+}
diff --git a/akon-core/src/config/mod.rs b/akon-core/src/config/mod.rs
index b46653e..59f5b0a 100644
--- a/akon-core/src/config/mod.rs
+++ b/akon-core/src/config/mod.rs
@@ -8,7 +8,9 @@ pub mod toml_config;
/// VPN protocol type
///
-/// Supported VPN protocols for OpenConnect
+/// VPN protocol identifier. akon's native client implements **F5** (the
+/// default); the other variants are recognized in config for forward
+/// compatibility but are not currently supported by the native backend.
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VpnProtocol {
@@ -30,7 +32,7 @@ pub enum VpnProtocol {
}
impl VpnProtocol {
- /// Get the protocol name as expected by OpenConnect
+ /// Get the lowercase protocol identifier (e.g. `"f5"`).
pub fn as_str(&self) -> &'static str {
match self {
Self::AnyConnect => "anyconnect",
@@ -56,7 +58,7 @@ pub struct VpnConfig {
/// Username for VPN authentication
pub username: String,
- /// VPN protocol to use (default: AnyConnect)
+ /// VPN protocol to use (default: F5)
#[serde(default)]
pub protocol: VpnProtocol,
diff --git a/akon-core/src/error.rs b/akon-core/src/error.rs
index 2893c2b..d813681 100644
--- a/akon-core/src/error.rs
+++ b/akon-core/src/error.rs
@@ -99,23 +99,11 @@ pub enum VpnError {
#[error("Network error: {reason}")]
NetworkError { reason: String },
- #[error("OpenConnect library error: {code}")]
- OpenConnectError { code: i32 },
-
#[error("Invalid connection state transition")]
InvalidStateTransition,
- #[error("Failed to spawn OpenConnect process: {reason}")]
- ProcessSpawnError { reason: String },
-
#[error("Connection timeout after {seconds} seconds")]
ConnectionTimeout { seconds: u64 },
-
- #[error("Failed to terminate OpenConnect process")]
- TerminationError,
-
- #[error("Failed to parse OpenConnect output: {line}")]
- ParseError { line: String },
}
/// OTP/TOTP operation errors
diff --git a/akon-core/src/types.rs b/akon-core/src/types.rs
index 3e752fb..09ce34a 100644
--- a/akon-core/src/types.rs
+++ b/akon-core/src/types.rs
@@ -140,8 +140,8 @@ impl VpnPassword {
/// Expose the password value (use with caution!)
///
- /// This should only be called when passing to OpenConnect or
- /// outputting to stdout for the get-password command.
+ /// This should only be called when submitting it to the VPN backend during
+ /// authentication, or outputting to stdout for the get-password command.
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
diff --git a/akon-core/src/vpn/backend.rs b/akon-core/src/vpn/backend.rs
new file mode 100644
index 0000000..21b1922
--- /dev/null
+++ b/akon-core/src/vpn/backend.rs
@@ -0,0 +1,237 @@
+//! Backend-agnostic VPN connection boundary
+//!
+//! This module defines the **durable abstraction** that decouples akon's
+//! orchestration logic from *how* a VPN connection is actually established.
+//!
+//! The production implementation is the native, in-process F5 client
+//! ([`crate::vpn::f5::NativeF5Backend`]). The boundary is also implemented by a
+//! `SimulatedBackend` test oracle, so the native backend is validated against
+//! the exact same scenario suite (cross-backend equivalence).
+//!
+//! Crucially, the vocabulary here ([`LifecycleEvent`]) is intentionally
+//! *backend-agnostic*: it describes connection lifecycle outcomes, not the
+//! mechanics of any particular implementation.
+
+use std::net::IpAddr;
+use tokio::sync::mpsc::UnboundedReceiver;
+
+/// Credentials handed to a backend to establish a connection.
+///
+/// The backend is responsible for transmitting these securely (the native F5
+/// backend posts the password over TLS). The framework never persists these.
+#[derive(Debug, Clone)]
+pub struct Credentials {
+ /// VPN username.
+ pub username: String,
+ /// Pre-computed password (e.g. `PIN + OTP`).
+ pub password: String,
+}
+
+impl Credentials {
+ /// Create new credentials.
+ pub fn new(username: impl Into, password: impl Into) -> Self {
+ Self {
+ username: username.into(),
+ password: password.into(),
+ }
+ }
+}
+
+/// An opaque handle to a live connection.
+///
+/// The native backend wraps an internal session identifier. Callers MUST treat
+/// it as opaque and not assume it is a PID.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct ConnectionHandle(pub u64);
+
+impl ConnectionHandle {
+ /// Raw numeric value of the handle (for diagnostics only).
+ pub fn raw(&self) -> u64 {
+ self.0
+ }
+}
+
+/// A termination signal to deliver to a connection (used by the test
+/// `SimulatedBackend` to model graceful vs. forced teardown).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TermSignal {
+ /// Graceful termination.
+ Graceful,
+ /// Forced termination.
+ Forced,
+}
+
+/// Why a connection ended.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DisconnectReason {
+ /// The user (or akon) requested disconnect.
+ UserRequested,
+ /// The server closed the session.
+ ServerClosed,
+ /// The underlying transport/process terminated unexpectedly.
+ LinkLost,
+}
+
+impl DisconnectReason {
+ /// Whether this disconnect was explicitly requested by the user/akon.
+ pub fn is_user_requested(&self) -> bool {
+ matches!(self, DisconnectReason::UserRequested)
+ }
+}
+
+/// Category of a terminal failure.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum FailureKind {
+ /// Credentials were rejected.
+ Authentication,
+ /// Network/transport failure (unreachable server, TLS, etc.).
+ Network,
+ /// A scripted test backend ran out of steps before a terminal event.
+ ScriptExhausted,
+ /// Any other backend-internal failure.
+ Backend,
+}
+
+/// Backend-agnostic, observable events emitted across a connection's lifetime.
+///
+/// This is the contract surface tests assert on. Ordering follows the state
+/// machine documented in `specs/005-test-actors-framework/data-model.md`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LifecycleEvent {
+ /// Connection attempt has begun.
+ Connecting,
+ /// Authentication is in progress.
+ Authenticating,
+ /// An authenticated session was established (pre-tunnel).
+ SessionEstablished,
+ /// The tunnel/interface is configured with an address.
+ LinkUp { ip: IpAddr, device: String },
+ /// The connection is fully usable.
+ Connected { ip: IpAddr, device: String },
+ /// The link is believed unhealthy/down (from health checks).
+ HealthDegraded,
+ /// A reconnection attempt is underway.
+ Reconnecting { attempt: u32 },
+ /// The connection ended normally.
+ Disconnected { reason: DisconnectReason },
+ /// The connection failed terminally.
+ Failed { kind: FailureKind, detail: String },
+}
+
+impl LifecycleEvent {
+ /// True if this is a terminal event (no further events expected).
+ pub fn is_terminal(&self) -> bool {
+ matches!(
+ self,
+ LifecycleEvent::Disconnected { .. } | LifecycleEvent::Failed { .. }
+ )
+ }
+
+ /// Short, stable label for diagnostics/timeline printing.
+ pub fn label(&self) -> &'static str {
+ match self {
+ LifecycleEvent::Connecting => "Connecting",
+ LifecycleEvent::Authenticating => "Authenticating",
+ LifecycleEvent::SessionEstablished => "SessionEstablished",
+ LifecycleEvent::LinkUp { .. } => "LinkUp",
+ LifecycleEvent::Connected { .. } => "Connected",
+ LifecycleEvent::HealthDegraded => "HealthDegraded",
+ LifecycleEvent::Reconnecting { .. } => "Reconnecting",
+ LifecycleEvent::Disconnected { .. } => "Disconnected",
+ LifecycleEvent::Failed { .. } => "Failed",
+ }
+ }
+}
+
+/// Errors a backend may return from its control methods.
+#[derive(Debug, thiserror::Error)]
+pub enum BackendError {
+ /// `connect` was called while already connected.
+ #[error("backend is already connected")]
+ AlreadyConnected,
+
+ /// A control operation was attempted before connecting.
+ #[error("backend is not connected")]
+ NotConnected,
+
+ /// The backend failed to start the connection.
+ #[error("failed to start connection: {0}")]
+ StartFailed(String),
+
+ /// Teardown failed.
+ #[error("failed to disconnect: {0}")]
+ DisconnectFailed(String),
+}
+
+/// The durable VPN backend abstraction.
+///
+/// Implementations: [`crate::vpn::f5::NativeF5Backend`] (production) and the
+/// `SimulatedBackend` test oracle.
+///
+/// ## Design note: why channel-based, not `async fn`
+///
+/// `connect` is synchronous and returns a stream ([`UnboundedReceiver`]) of
+/// lifecycle events. The backend performs its asynchronous work on internally
+/// spawned tasks and pushes events into the channel. This mirrors the existing
+/// actor pattern in [`crate::vpn::reconnection`] and avoids pulling in an
+/// `async-trait` dependency, keeping the crate dependency-light (in line with
+/// the project's goal of eventually shipping with no required dependencies).
+pub trait VpnBackend: Send {
+ /// Begin establishing a connection.
+ ///
+ /// Returns a receiver of [`LifecycleEvent`]s. The stream ends after a
+ /// terminal event ([`LifecycleEvent::is_terminal`]).
+ fn connect(
+ &mut self,
+ credentials: Credentials,
+ ) -> Result, BackendError>;
+
+ /// Tear down the connection. Idempotent: calling it on an
+ /// already-disconnected backend is a successful no-op.
+ fn disconnect(&mut self) -> Result<(), BackendError>;
+
+ /// Whether the connection/tunnel is currently alive.
+ fn is_alive(&self) -> bool;
+
+ /// Opaque handle to the live connection, if any.
+ fn handle(&self) -> Option;
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn terminal_events_are_terminal() {
+ assert!(LifecycleEvent::Disconnected {
+ reason: DisconnectReason::UserRequested
+ }
+ .is_terminal());
+ assert!(LifecycleEvent::Failed {
+ kind: FailureKind::Network,
+ detail: "x".into()
+ }
+ .is_terminal());
+ assert!(!LifecycleEvent::Connecting.is_terminal());
+ assert!(!LifecycleEvent::Connected {
+ ip: "10.0.0.1".parse().unwrap(),
+ device: "tun0".into()
+ }
+ .is_terminal());
+ }
+
+ #[test]
+ fn labels_are_stable() {
+ assert_eq!(LifecycleEvent::Connecting.label(), "Connecting");
+ assert_eq!(
+ LifecycleEvent::Reconnecting { attempt: 2 }.label(),
+ "Reconnecting"
+ );
+ }
+
+ #[test]
+ fn handle_is_opaque_but_inspectable() {
+ let h = ConnectionHandle(42);
+ assert_eq!(h.raw(), 42);
+ }
+}
diff --git a/akon-core/src/vpn/cli_connector.rs b/akon-core/src/vpn/cli_connector.rs
deleted file mode 100644
index 06f74b7..0000000
--- a/akon-core/src/vpn/cli_connector.rs
+++ /dev/null
@@ -1,493 +0,0 @@
-//! CLI-based OpenConnect connection manager
-//!
-//! Manages OpenConnect CLI process lifecycle from spawn to termination
-
-use crate::config::VpnConfig;
-use crate::error::{AkonError, VpnError};
-use crate::vpn::{ConnectionEvent, ConnectionState, DisconnectReason, OutputParser};
-use std::process::Stdio;
-use std::sync::Arc;
-use std::time::Duration;
-use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
-use tokio::process::{Child, ChildStdin, Command};
-use tokio::sync::{mpsc, Mutex};
-
-/// CLI-based OpenConnect connection manager
-pub struct CliConnector {
- /// Current connection state
- state: Arc>,
-
- /// Optional handle to running OpenConnect process (may be sudo wrapper)
- child_process: Arc>>,
-
- /// Actual OpenConnect process PID (not the sudo wrapper)
- openconnect_pid: Arc>>,
-
- /// OpenConnect stdin - kept alive to prevent process termination
- process_stdin: Arc>>,
-
- /// Channel for receiving connection events
- event_receiver: mpsc::UnboundedReceiver,
-
- /// Channel sender (kept for cloning to monitor tasks)
- event_sender: mpsc::UnboundedSender,
-
- /// Parser for OpenConnect output
- parser: Arc,
-
- /// Configuration (server URL, protocol)
- config: VpnConfig,
-}
-
-impl CliConnector {
- /// Create new connector with configuration
- pub fn new(config: VpnConfig) -> Result {
- let (event_sender, event_receiver) = mpsc::unbounded_channel();
-
- Ok(Self {
- state: Arc::new(Mutex::new(ConnectionState::Idle)),
- child_process: Arc::new(Mutex::new(None)),
- openconnect_pid: Arc::new(Mutex::new(None)),
- process_stdin: Arc::new(Mutex::new(None)),
- event_receiver,
- event_sender,
- parser: Arc::new(OutputParser::new()),
- config,
- })
- }
-
- /// Get current connection state
- pub fn state(&self) -> ConnectionState {
- // This is a synchronous method, but we need to handle the async Mutex
- // For now, we'll use try_lock which is available
- self.state
- .try_lock()
- .map(|guard| guard.clone())
- .unwrap_or(ConnectionState::Idle)
- }
-
- /// Check if currently connected
- pub fn is_connected(&self) -> bool {
- matches!(self.state(), ConnectionState::Established { .. })
- }
-
- /// Get the process ID of the running OpenConnect process
- ///
- /// Returns the actual openconnect PID, not the sudo wrapper PID
- pub fn get_pid(&self) -> Option {
- self.openconnect_pid
- .try_lock()
- .ok()
- .and_then(|guard| *guard)
- }
-
- /// Find the OpenConnect daemon process PID
- ///
- /// When openconnect uses --background, it daemonizes and we need to find
- /// it by process name and command line matching our server
- async fn find_openconnect_daemon_pid(server: &str) -> Option {
- // Wait a bit for daemon to start
- tokio::time::sleep(Duration::from_millis(200)).await;
-
- // Try multiple times in case daemon hasn't started yet
- for attempt in 0..15 {
- // Use pgrep to find openconnect processes matching our server
- let output = tokio::process::Command::new("pgrep")
- .args(["-f", &format!("openconnect.*{}", server)])
- .output()
- .await;
-
- if let Ok(output) = output {
- if output.status.success() {
- let stdout = String::from_utf8_lossy(&output.stdout);
- // Parse PID (take the first one if multiple)
- for line in stdout.lines() {
- if let Ok(pid) = line.trim().parse::() {
- tracing::debug!(
- "Found OpenConnect daemon PID {} for server {}",
- pid,
- server
- );
- return Some(pid);
- }
- }
- }
- }
-
- // Wait a bit and retry
- if attempt < 14 {
- tokio::time::sleep(Duration::from_millis(100)).await;
- }
- }
-
- tracing::warn!(
- "Could not find OpenConnect daemon process for server {}",
- server
- );
- None
- }
-
- /// Spawn OpenConnect process with credentials
- ///
- /// Returns the spawned child process
- async fn spawn_process(&self) -> Result {
- // Use sudo to run openconnect since it requires root privileges for network configuration
- let mut cmd = Command::new("sudo");
- cmd.arg("openconnect")
- .arg("--protocol")
- .arg(self.config.protocol.as_str())
- .arg("--user")
- .arg(&self.config.username)
- .arg("--passwd-on-stdin")
- .arg("--background"); // Daemonize to stay running
-
- // Add --no-dtls flag if configured
- if self.config.no_dtls {
- cmd.arg("--no-dtls");
- tracing::debug!("DTLS disabled per configuration");
- }
-
- // Add server (without explicit port, let openconnect use default)
- cmd.arg(&self.config.server)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped());
-
- // Spawn the process
- let child = cmd.spawn().map_err(|e| VpnError::ProcessSpawnError {
- reason: format!("Failed to spawn openconnect: {}", e),
- })?;
-
- tracing::debug!("OpenConnect process spawned with PID: {:?}", child.id());
- Ok(child)
- }
-
- /// Send password to OpenConnect via stdin
- ///
- /// Writes password and keeps stdin open (closing it would terminate openconnect)
- async fn send_password(&self, child: &mut Child, password: &str) -> Result<(), VpnError> {
- if let Some(mut stdin) = child.stdin.take() {
- stdin.write_all(password.as_bytes()).await.map_err(|e| {
- VpnError::ProcessSpawnError {
- reason: format!("Failed to write password to stdin: {}", e),
- }
- })?;
-
- stdin
- .write_all(b"\n")
- .await
- .map_err(|e| VpnError::ProcessSpawnError {
- reason: format!("Failed to write newline to stdin: {}", e),
- })?;
-
- stdin
- .flush()
- .await
- .map_err(|e| VpnError::ProcessSpawnError {
- reason: format!("Failed to flush stdin: {}", e),
- })?;
-
- // Store stdin to keep it alive - closing it would terminate openconnect
- {
- let mut stdin_lock = self.process_stdin.lock().await;
- *stdin_lock = Some(stdin);
- }
- tracing::debug!("Password sent to OpenConnect, stdin kept alive");
- }
- Ok(())
- }
-
- /// Connect to VPN
- ///
- /// Spawns OpenConnect, sends credentials, waits for connection, then detaches
- pub async fn connect(&mut self, password: String) -> Result<(), VpnError> {
- // Update state to Connecting
- {
- let mut state = self.state.lock().await;
- *state = ConnectionState::Connecting;
- }
-
- // Spawn OpenConnect process (via sudo wrapper with --background flag)
- let mut child = self.spawn_process().await?;
- let sudo_pid = child.id().unwrap_or(0);
-
- tracing::info!("Spawned sudo wrapper with PID {}", sudo_pid);
-
- // Send password via stdin (do this immediately while sudo is running)
- self.send_password(&mut child, &password).await?;
-
- // Take stdout and stderr for monitoring connection status
- let stdout = child
- .stdout
- .take()
- .ok_or_else(|| VpnError::ProcessSpawnError {
- reason: "Failed to capture stdout".to_string(),
- })?;
-
- let stderr = child
- .stderr
- .take()
- .ok_or_else(|| VpnError::ProcessSpawnError {
- reason: "Failed to capture stderr".to_string(),
- })?;
-
- // Monitor both stdout and stderr until we see connection success or error
- let parser = Arc::clone(&self.parser);
- let event_sender = self.event_sender.clone();
- let parser_stderr = Arc::clone(&self.parser);
- let event_sender_stderr = self.event_sender.clone();
-
- let mut stdout_reader = BufReader::new(stdout).lines();
- let mut stderr_reader = BufReader::new(stderr).lines();
- let mut connected = false;
- let mut ip_address = None;
- let mut device = None;
- let mut authenticating_sent = false;
- let mut last_error: Option = None;
-
- // Spawn a task to monitor stderr in parallel
- let stderr_handle = tokio::spawn(async move {
- while let Ok(Some(line)) = stderr_reader.next_line().await {
- tracing::debug!("OpenConnect stderr: {}", line);
- let event = parser_stderr.parse_error(&line);
- let _ = event_sender_stderr.send(event);
- }
- });
-
- // Read stdout until connection is established or error occurs
- while let Ok(Some(line)) = stdout_reader.next_line().await {
- tracing::debug!("OpenConnect stdout: {}", line);
-
- // Parse the line for connection events
- let event = parser.parse_line(&line);
- match &event {
- ConnectionEvent::Connected { ip, device: dev } => {
- connected = true;
- ip_address = Some(ip.to_string());
- device = Some(dev.clone());
- let _ = event_sender.send(event.clone());
- break; // Stop monitoring once connected
- }
- ConnectionEvent::Error { kind, raw_output } => {
- let error_msg = format!("{:?}: {}", kind, raw_output);
- last_error = Some(error_msg.clone());
- let _ = event_sender.send(event.clone());
- // Continue reading to see if there are more specific errors
- }
- ConnectionEvent::Authenticating { .. } => {
- // Only send the first authenticating event to avoid duplicates
- if !authenticating_sent {
- let _ = event_sender.send(event.clone());
- authenticating_sent = true;
- }
- }
- _ => {
- let _ = event_sender.send(event.clone());
- }
- }
- }
-
- // Cancel stderr monitoring
- stderr_handle.abort();
-
- if !connected {
- // Check if we captured any error messages
- if let Some(error) = last_error {
- return Err(VpnError::ConnectionFailed { reason: error });
- }
-
- return Err(VpnError::ConnectionFailed {
- reason: format!(
- "No response from server '{}'. Please verify the server address is correct.",
- self.config.server
- ),
- });
- }
-
- // Find the daemonized OpenConnect process PID
- let daemon_pid = Self::find_openconnect_daemon_pid(&self.config.server).await;
-
- // Store the daemon PID
- let final_pid = daemon_pid.ok_or_else(|| VpnError::ProcessSpawnError {
- reason: "Could not find openconnect daemon process".to_string(),
- })?;
-
- {
- let mut pid_lock = self.openconnect_pid.lock().await;
- *pid_lock = Some(final_pid);
- }
-
- tracing::info!("OpenConnect daemonized with PID {}", final_pid);
-
- // Send ProcessStarted event with the actual PID
- let _ = event_sender.send(ConnectionEvent::ProcessStarted { pid: final_pid });
-
- // Update state to Established
- {
- let mut state = self.state.lock().await;
- *state = ConnectionState::Established {
- ip: ip_address
- .unwrap_or_default()
- .parse()
- .unwrap_or("0.0.0.0".parse().unwrap()),
- device: device.unwrap_or_default(),
- };
- }
-
- // Drop child handle - let openconnect run independently as a daemon
- // We only keep the PID for status checks and disconnect operations
- drop(child);
- tracing::info!("Detached from OpenConnect daemon, returning control to user");
-
- Ok(())
- }
-
- /// Get next connection event
- ///
- /// Returns None if event channel is closed
- pub async fn next_event(&mut self) -> Option {
- self.event_receiver.recv().await
- }
-
- /// Gracefully disconnect VPN
- ///
- /// Sends SIGTERM and waits up to 5 seconds before force-killing
- pub async fn disconnect(&mut self) -> Result<(), VpnError> {
- use nix::sys::signal::{kill, Signal};
- use nix::unistd::Pid;
-
- // Update state
- {
- let mut state = self.state.lock().await;
- *state = ConnectionState::Disconnecting;
- }
-
- // Get the actual OpenConnect PID
- let pid_opt = {
- let pid_lock = self.openconnect_pid.lock().await;
- *pid_lock
- };
-
- if let Some(pid_num) = pid_opt {
- let pid = Pid::from_raw(pid_num as i32);
-
- // Check if process exists
- if kill(pid, None).is_err() {
- tracing::info!("OpenConnect process {} already terminated", pid);
-
- // Clean up state
- {
- let mut pid_lock = self.openconnect_pid.lock().await;
- *pid_lock = None;
- }
- {
- let mut child_lock = self.child_process.lock().await;
- *child_lock = None;
- }
- {
- let mut stdin_lock = self.process_stdin.lock().await;
- *stdin_lock = None; // Close stdin
- }
- return Ok(());
- }
-
- tracing::info!("Sending SIGTERM to OpenConnect process {}", pid);
-
- // Try graceful termination with SIGTERM
- if let Err(e) = kill(pid, Signal::SIGTERM) {
- tracing::error!("Failed to send SIGTERM: {}", e);
- return Err(VpnError::TerminationError);
- }
-
- // Wait with timeout for process to exit
- let mut attempts = 0;
- let max_attempts = 10; // 5 seconds (500ms * 10)
-
- loop {
- tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
- attempts += 1;
-
- // Check if process still exists
- match kill(pid, None) {
- Err(_) => {
- // Process no longer exists
- tracing::info!("OpenConnect process terminated gracefully");
- break;
- }
- Ok(_) if attempts >= max_attempts => {
- // Timeout - force kill
- tracing::warn!("Graceful shutdown timed out, sending SIGKILL");
- if let Err(e) = kill(pid, Signal::SIGKILL) {
- tracing::error!("Failed to send SIGKILL: {}", e);
- return Err(VpnError::TerminationError);
- }
-
- // Wait a bit for SIGKILL to take effect
- tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
- tracing::warn!("Sent SIGKILL to process {}", pid);
- break;
- }
- _ => {
- // Still running, continue waiting
- continue;
- }
- }
- }
-
- // Clean up state
- {
- let mut pid_lock = self.openconnect_pid.lock().await;
- *pid_lock = None;
- }
- }
-
- // Clean up child process handle
- {
- let mut child_lock = self.child_process.lock().await;
- *child_lock = None;
- }
-
- // Update state to Idle
- {
- let mut state = self.state.lock().await;
- *state = ConnectionState::Idle;
- }
-
- // Send disconnect event
- let _ = self.event_sender.send(ConnectionEvent::Disconnected {
- reason: DisconnectReason::UserRequested,
- });
-
- Ok(())
- }
-
- /// Force kill the process with SIGKILL
- async fn force_kill_internal(&self, child: &mut Child) -> Result<(), VpnError> {
- if let Some(pid) = child.id() {
- use nix::sys::signal::{kill, Signal};
- use nix::unistd::Pid;
-
- let pid = Pid::from_raw(pid as i32);
- kill(pid, Signal::SIGKILL).map_err(|_| VpnError::TerminationError)?;
- tracing::warn!("Sent SIGKILL to process {}", pid);
- }
- Ok(())
- }
-
- /// Force kill VPN connection
- pub async fn force_kill(&mut self) -> Result<(), VpnError> {
- let mut child_lock = self.child_process.lock().await;
- if let Some(child) = child_lock.as_mut() {
- self.force_kill_internal(child).await?;
- *child_lock = None;
- }
-
- // Update state to Idle
- {
- let mut state = self.state.lock().await;
- *state = ConnectionState::Idle;
- }
-
- Ok(())
- }
-}
diff --git a/akon-core/src/vpn/connection_event.rs b/akon-core/src/vpn/connection_event.rs
deleted file mode 100644
index 7e3ff2a..0000000
--- a/akon-core/src/vpn/connection_event.rs
+++ /dev/null
@@ -1,56 +0,0 @@
-//! Connection event types for VPN lifecycle state machine
-//!
-//! Defines events emitted during OpenConnect CLI connection lifecycle
-
-use crate::error::VpnError;
-use std::net::IpAddr;
-
-/// Events emitted during OpenConnect CLI connection lifecycle
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ConnectionEvent {
- /// OpenConnect process started successfully
- ProcessStarted { pid: u32 },
-
- /// Authentication phase in progress
- Authenticating { message: String },
-
- /// F5 session manager connection established
- F5SessionEstablished {
- session_token: Option, // May be redacted for security
- },
-
- /// TUN device configured with assigned IP
- TunConfigured { device: String, ip: IpAddr },
-
- /// Full VPN connection established
- Connected { ip: IpAddr, device: String },
-
- /// Connection disconnected normally
- Disconnected { reason: DisconnectReason },
-
- /// Error occurred during connection
- Error { kind: VpnError, raw_output: String },
-
- /// Unparsed output line (fallback)
- UnknownOutput { line: String },
-}
-
-/// Reasons for disconnection
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum DisconnectReason {
- UserRequested,
- ServerDisconnect,
- ProcessTerminated,
- Timeout,
-}
-
-/// Internal connection state
-#[derive(Debug, Clone, PartialEq)]
-pub enum ConnectionState {
- Idle,
- Connecting,
- Authenticating,
- Established { ip: IpAddr, device: String },
- Disconnecting,
- Failed { error: String },
-}
diff --git a/akon-core/src/vpn/f5/auth.rs b/akon-core/src/vpn/f5/auth.rs
new file mode 100644
index 0000000..71aeed4
--- /dev/null
+++ b/akon-core/src/vpn/f5/auth.rs
@@ -0,0 +1,490 @@
+//! F5 HTTP auth logic for the native backend (pure Rust).
+//!
+//! F5 BIG-IP SSL VPN authenticates over HTTPS. The login form (`id="auth_form"`)
+//! POSTs `username`/`password` as `application/x-www-form-urlencoded`. Auth
+//! success is signalled not by a single cookie but by the **combination** of two
+//! `Set-Cookie` values: `MRHSession` (often re-set repeatedly before auth
+//! completes) and `F5_ST` (the "session timeout" cookie). Only when both are
+//! present is the session established, and the subsequent requests carry the
+//! combined `Cookie: MRHSession=; F5_ST=` header.
+//!
+//! Protocol ground truth: openconnect `f5.c` (`check_cookie_success`) — both
+//! cookies required, combined header formatted as
+//! `"MRHSession=%s; F5_ST=%s"`.
+
+use std::collections::HashMap;
+
+/// The F5 session cookie. Set repeatedly during the exchange; not sufficient on
+/// its own to indicate auth success.
+pub const COOKIE_MRHSESSION: &str = "MRHSession";
+
+/// The F5 "session timeout" cookie. Its presence (together with [`COOKIE_MRHSESSION`])
+/// indicates that authentication has completed.
+pub const COOKIE_F5_ST: &str = "F5_ST";
+
+/// Accumulates `Set-Cookie` values seen during the auth exchange and reports
+/// when the F5 session is established (both [`COOKIE_MRHSESSION`] and
+/// [`COOKIE_F5_ST`] present).
+#[derive(Debug, Default, Clone)]
+pub struct F5CookieJar {
+ cookies: HashMap,
+}
+
+impl F5CookieJar {
+ /// Create an empty cookie jar.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Feed a raw `Set-Cookie` header value (e.g. `"MRHSession=abc; path=/; secure"`).
+ ///
+ /// Only the first `name=value` pair is significant; cookie attributes
+ /// (`path`, `secure`, `HttpOnly`, ...) after the first `;` are ignored. An
+ /// empty value clears the cookie (servers delete cookies by re-setting an
+ /// empty value); anything else stores/overwrites it.
+ pub fn ingest_set_cookie(&mut self, header_value: &str) {
+ let trimmed = header_value.trim_start();
+ let pair = trimmed.split(';').next().unwrap_or("").trim();
+ let Some((name, value)) = pair.split_once('=') else {
+ return;
+ };
+ let name = name.trim();
+ let value = value.trim();
+ if name.is_empty() {
+ return;
+ }
+ if value.is_empty() {
+ self.cookies.remove(name);
+ } else {
+ self.cookies.insert(name.to_string(), value.to_string());
+ }
+ }
+
+ /// Get the stored value of cookie `name`, if present.
+ pub fn get(&self, name: &str) -> Option<&str> {
+ self.cookies.get(name).map(String::as_str)
+ }
+
+ /// True iff both `MRHSession` and `F5_ST` are present (auth success).
+ pub fn is_authenticated(&self) -> bool {
+ self.cookies.contains_key(COOKIE_MRHSESSION) && self.cookies.contains_key(COOKIE_F5_ST)
+ }
+
+ /// The combined `Cookie` header value `"MRHSession=..; F5_ST=.."`, or `None`
+ /// if not yet authenticated.
+ pub fn cookie_header(&self) -> Option {
+ let session = self.cookies.get(COOKIE_MRHSESSION)?;
+ let f5_st = self.cookies.get(COOKIE_F5_ST)?;
+ Some(format!(
+ "{COOKIE_MRHSESSION}={session}; {COOKIE_F5_ST}={f5_st}"
+ ))
+ }
+
+ /// A `Cookie` header echoing **all** currently-held cookies (joined by
+ /// `"; "`), or `None` if the jar is empty.
+ ///
+ /// openconnect re-sends every cookie on every request during the auth/redirect
+ /// chain; the F5 frontend often sets intermediate session cookies (e.g. a
+ /// `LastMRH_Session`, `MRHSession`, policy cookies) that must be echoed back
+ /// for the next step to succeed. This returns them all, sorted for
+ /// determinism.
+ pub fn cookie_header_all(&self) -> Option {
+ if self.cookies.is_empty() {
+ return None;
+ }
+ let mut pairs: Vec<(&String, &String)> = self.cookies.iter().collect();
+ pairs.sort_by(|a, b| a.0.cmp(b.0));
+ Some(
+ pairs
+ .into_iter()
+ .map(|(k, v)| format!("{k}={v}"))
+ .collect::>()
+ .join("; "),
+ )
+ }
+}
+
+/// A field of an F5 login form.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FormField {
+ /// The `name` attribute.
+ pub name: String,
+ /// Field kind, lowercased (`text`, `password`, `hidden`, ...).
+ pub kind: String,
+ /// Any prefilled `value` attribute.
+ pub value: String,
+}
+
+impl FormField {
+ /// Whether this is a password field (the slot for PIN+OTP).
+ pub fn is_password(&self) -> bool {
+ self.kind == "password"
+ }
+
+ /// Whether this is a free-text field that should receive the username
+ /// (a text/username/email input).
+ pub fn is_username(&self) -> bool {
+ matches!(self.kind.as_str(), "text" | "username" | "email")
+ }
+}
+
+/// A parsed F5 HTML login form (`")
+ .map(|i| after_form_tag + i)
+ .unwrap_or(html.len());
+ let body = &html[after_form_tag..form_end];
+
+ let mut fields = Vec::new();
+ let lower_body = body.to_ascii_lowercase();
+ let mut cursor = 0;
+ while let Some(rel) = lower_body[cursor..].find("')
+ .map(|i| start + i + 1)
+ .unwrap_or(body.len());
+ let tag = &body[start..end];
+ let name = tag_attr(tag, "name").unwrap_or_default();
+ if !name.is_empty() {
+ fields.push(FormField {
+ name,
+ kind: tag_attr(tag, "type").unwrap_or_else(|| "text".to_string()),
+ value: tag_attr(tag, "value").unwrap_or_default(),
+ });
+ }
+ cursor = end;
+ }
+
+ Some(F5AuthForm { id, action, fields })
+ }
+
+ /// Build the urlencoded POST body for this form, filling the username and
+ /// password slots and preserving all other fields (including hidden ones)
+ /// with their existing values.
+ ///
+ /// `password` is akon's pre-composed PIN+OTP string. For a single-step F5
+ /// login this is the complete OTP-inclusive credential.
+ pub fn build_submission(&self, username: &str, password: &str) -> String {
+ let mut parts: Vec = Vec::new();
+ for field in &self.fields {
+ let value = if field.is_password() {
+ password.to_string()
+ } else if field.is_username() {
+ username.to_string()
+ } else {
+ field.value.clone()
+ };
+ parts.push(format!(
+ "{}={}",
+ percent_encode(&field.name),
+ percent_encode(&value)
+ ));
+ }
+ // Fallback to the canonical fields if the form had no parsed inputs.
+ if parts.is_empty() {
+ return build_login_body(username, password);
+ }
+ parts.join("&")
+ }
+}
+
+/// Extract an attribute value from a tag string (``), tolerant
+/// of single/double quotes and surrounding whitespace. Case-insensitive name.
+fn tag_attr(tag: &str, attr: &str) -> Option {
+ let lower = tag.to_ascii_lowercase();
+ let needle = format!("{}=", attr);
+ let mut search = 0;
+ while let Some(rel) = lower[search..].find(&needle) {
+ let at = search + rel;
+ // Ensure the char before the attr name is a word boundary (space, quote,
+ // or tag start) to avoid matching substrings like "xid=".
+ let prev_ok = at == 0
+ || lower.as_bytes()[at - 1].is_ascii_whitespace()
+ || lower.as_bytes()[at - 1] == b'<';
+ let val_start = at + needle.len();
+ if !prev_ok || val_start >= tag.len() {
+ search = at + needle.len();
+ continue;
+ }
+ let bytes = tag.as_bytes();
+ let (quote, content_start) = match bytes[val_start] {
+ b'"' => (Some(b'"'), val_start + 1),
+ b'\'' => (Some(b'\''), val_start + 1),
+ _ => (None, val_start),
+ };
+ let content = &tag[content_start..];
+ let end = match quote {
+ Some(q) => content.find(q as char).unwrap_or(content.len()),
+ None => content
+ .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
+ .unwrap_or(content.len()),
+ };
+ return Some(content[..end].to_string());
+ }
+ None
+}
+
+/// Build the urlencoded body for the credential POST: `"username=..&password=.."`.
+///
+/// Encoding: application/x-www-form-urlencoded with strict percent-encoding.
+/// Only the unreserved set `A-Z a-z 0-9 - _ . ~` is left literal; every other
+/// byte (including space, `&`, `=`, `+`, `%`, `@`) is percent-encoded as
+/// `%XX` with upper-case hex. (Space is encoded as `%20`, not `+`, so the body
+/// round-trips unambiguously.)
+pub fn build_login_body(username: &str, password: &str) -> String {
+ format!(
+ "username={}&password={}",
+ percent_encode(username),
+ percent_encode(password)
+ )
+}
+
+/// Percent-encode a string per the strict `application/x-www-form-urlencoded`
+/// rules used by [`build_login_body`]: unreserved chars literal, everything
+/// else `%XX` (upper-case hex).
+fn percent_encode(input: &str) -> String {
+ const HEX: &[u8; 16] = b"0123456789ABCDEF";
+ let mut out = String::with_capacity(input.len());
+ for &byte in input.as_bytes() {
+ if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
+ out.push(byte as char);
+ } else {
+ out.push('%');
+ out.push(HEX[(byte >> 4) as usize] as char);
+ out.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ }
+ out
+}
+
+/// Parse the F5 `F5_ST` cookie value.
+///
+/// The value is a `z`-separated record (openconnect format `"%dz%dz%dz%lldz%lld"`).
+/// The 4th field is the session `start` time and the 5th is the `dur`ation.
+/// Returns `Some((start, dur))` when at least five `z`-separated integer fields
+/// are present, else `None`.
+pub fn parse_f5_st(value: &str) -> Option<(i64, i64)> {
+ let mut fields = value.split('z');
+ let _f0 = fields.next()?.parse::().ok()?;
+ let _f1 = fields.next()?.parse::().ok()?;
+ let _f2 = fields.next()?.parse::().ok()?;
+ let start = fields.next()?.parse::().ok()?;
+ let dur = fields.next()?.parse::().ok()?;
+ Some((start, dur))
+}
+
+/// Extract the value of `name` from the first `name=value` pair of a
+/// `Cookie`/`Set-Cookie` style string (stops at the first `;`).
+///
+/// Returns the trimmed value, or `None` if the leading pair's name does not
+/// match `name` or there is no `=`.
+pub fn extract_cookie_pair(header_value: &str, name: &str) -> Option {
+ let pair = header_value.split(';').next()?.trim();
+ let (k, v) = pair.split_once('=')?;
+ if k.trim() == name {
+ Some(v.trim().to_string())
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn both_cookies_authenticate_and_build_combined_header() {
+ let mut jar = F5CookieJar::new();
+ jar.ingest_set_cookie("MRHSession=abc; path=/; secure");
+ jar.ingest_set_cookie("F5_ST=1z2z3z100z200; path=/");
+
+ assert!(jar.is_authenticated());
+ assert_eq!(jar.get("MRHSession"), Some("abc"));
+ assert_eq!(jar.get("F5_ST"), Some("1z2z3z100z200"));
+ assert_eq!(
+ jar.cookie_header().as_deref(),
+ Some("MRHSession=abc; F5_ST=1z2z3z100z200")
+ );
+ }
+
+ #[test]
+ fn only_mrhsession_is_not_authenticated() {
+ let mut jar = F5CookieJar::new();
+ jar.ingest_set_cookie("MRHSession=abc; path=/; secure");
+
+ assert!(!jar.is_authenticated());
+ assert_eq!(jar.cookie_header(), None);
+ }
+
+ #[test]
+ fn only_f5_st_is_not_authenticated() {
+ let mut jar = F5CookieJar::new();
+ jar.ingest_set_cookie("F5_ST=1z2z3z100z200");
+
+ assert!(!jar.is_authenticated());
+ assert_eq!(jar.cookie_header(), None);
+ }
+
+ #[test]
+ fn mrhsession_can_be_re_set_before_auth_completes() {
+ let mut jar = F5CookieJar::new();
+ jar.ingest_set_cookie("MRHSession=first; path=/");
+ jar.ingest_set_cookie("MRHSession=second; path=/");
+ assert_eq!(jar.get("MRHSession"), Some("second"));
+ assert!(!jar.is_authenticated());
+ }
+
+ #[test]
+ fn empty_value_clears_cookie() {
+ let mut jar = F5CookieJar::new();
+ jar.ingest_set_cookie("MRHSession=abc");
+ jar.ingest_set_cookie("F5_ST=xyz");
+ assert!(jar.is_authenticated());
+ // Server deletes the cookie by re-setting an empty value.
+ jar.ingest_set_cookie("F5_ST=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
+ assert!(!jar.is_authenticated());
+ assert_eq!(jar.get("F5_ST"), None);
+ }
+
+ #[test]
+ fn login_body_percent_encodes_reserved_chars() {
+ let body = build_login_body("user@x", "p&ss word");
+ assert!(
+ body.contains("username=user%40x"),
+ "body did not encode @: {body}"
+ );
+ assert!(
+ body.contains("password=p%26ss%20word"),
+ "body did not encode & and space: {body}"
+ );
+ assert_eq!(body, "username=user%40x&password=p%26ss%20word");
+ }
+
+ #[test]
+ fn login_body_leaves_unreserved_literal() {
+ let body = build_login_body("a-b_c.d~e", "AZaz09");
+ assert_eq!(body, "username=a-b_c.d~e&password=AZaz09");
+ }
+
+ #[test]
+ fn login_body_encodes_plus_equals_percent() {
+ let body = build_login_body("a+b", "x=y%z");
+ assert_eq!(body, "username=a%2Bb&password=x%3Dy%25z");
+ }
+
+ #[test]
+ fn parse_f5_st_extracts_start_and_dur() {
+ assert_eq!(
+ parse_f5_st("0z0z0z1700000000z3600"),
+ Some((1700000000, 3600))
+ );
+ }
+
+ #[test]
+ fn parse_f5_st_rejects_garbage() {
+ assert_eq!(parse_f5_st("garbage"), None);
+ assert_eq!(parse_f5_st("1z2z3z4"), None); // too few fields
+ assert_eq!(parse_f5_st("1z2z3zNOTINTz5"), None); // non-integer field
+ }
+
+ #[test]
+ fn extract_cookie_pair_matches_leading_name() {
+ assert_eq!(
+ extract_cookie_pair("MRHSession=abc; path=/; secure", "MRHSession").as_deref(),
+ Some("abc")
+ );
+ assert_eq!(extract_cookie_pair("MRHSession=abc", "F5_ST"), None);
+ assert_eq!(extract_cookie_pair("novalue", "novalue"), None);
+ }
+
+ #[test]
+ fn parse_auth_form_basic() {
+ let html = "\
+
\
+\
+\
+
";
+ let form = F5AuthForm::parse(html).expect("form parsed");
+ assert_eq!(form.id, "auth_form");
+ assert_eq!(form.action, "/my.policy");
+ assert_eq!(form.fields.len(), 2);
+ assert!(form.fields[0].is_username());
+ assert!(form.fields[1].is_password());
+ }
+
+ #[test]
+ fn build_submission_fills_user_password_and_preserves_hidden() {
+ let html = "
\
+\
+\
+\
+
";
+ let form = F5AuthForm::parse(html).unwrap();
+ // password carries akon's PIN+OTP (single string).
+ let body = form.build_submission("testuser", "1234567890");
+ assert!(
+ body.contains("vhost=standard"),
+ "hidden not preserved: {body}"
+ );
+ assert!(
+ body.contains("username=testuser"),
+ "username missing: {body}"
+ );
+ assert!(
+ body.contains("password=1234567890"),
+ "password missing: {body}"
+ );
+ }
+
+ #[test]
+ fn parse_auth_form_tolerates_single_quotes_and_attr_order() {
+ let html = "
\
+\
+\
+
";
+ let form = F5AuthForm::parse(html).unwrap();
+ assert_eq!(form.id, "auth_form");
+ assert_eq!(form.action, "/step2");
+ assert_eq!(form.fields.len(), 2);
+ }
+
+ #[test]
+ fn parse_returns_none_without_form() {
+ assert!(F5AuthForm::parse("no form here").is_none());
+ }
+
+ #[test]
+ fn tag_attr_avoids_substring_false_match() {
+ // "xid" must not match "id".
+ let tag = "
";
+ assert_eq!(tag_attr(tag, "id").as_deref(), Some("real"));
+ }
+}
diff --git a/akon-core/src/vpn/f5/backend.rs b/akon-core/src/vpn/f5/backend.rs
new file mode 100644
index 0000000..df2308d
--- /dev/null
+++ b/akon-core/src/vpn/f5/backend.rs
@@ -0,0 +1,1155 @@
+//! `NativeF5Backend` — orchestrates the native F5 layers and implements
+//! [`VpnBackend`].
+//!
+//! Flow (per openconnect `f5.c`, validated by the test actors framework):
+//! 1. **Auth**: GET `/` → parse `auth_form` → POST `username`/`password` →
+//! collect `MRHSession` + `F5_ST` cookies.
+//! 2. **Config**: GET profile XML → ``; GET options XML → session id,
+//! `ur_Z`, ipv4/ipv6/hdlc, DNS, routes.
+//! 3. **Tunnel upgrade**: GET `/myvpn?sess=&hdlc_framing=&ipv4=&ipv6=&Z=&hostname=`
+//! (no Cookie) → expect 200/201, read `X-VPN-client-IP`.
+//! 4. **PPP**: run LCP then IPCP to "network up" using the negotiated IP/DNS.
+//!
+//! All socket I/O goes through the [`Transport`] seam, so the entire flow is
+//! exercised offline against the fake F5 server actor.
+
+use crate::vpn::backend::{
+ BackendError, ConnectionHandle, Credentials, FailureKind, LifecycleEvent, VpnBackend,
+};
+use crate::vpn::f5::auth::{F5AuthForm, F5CookieJar};
+use crate::vpn::f5::config::{parse_options, parse_profile, F5Options};
+use crate::vpn::f5::dns::{DnsApplier, NoopDns};
+use crate::vpn::f5::framing::{f5_decap, f5_encap};
+use crate::vpn::f5::http::{send_request, HttpRequest, HttpResponse};
+use crate::vpn::f5::ppp::{lcp_terminate_request, PppNegotiator, PppPhase};
+use crate::vpn::f5::F5Error;
+use crate::vpn::transport::{NoopTun, Transport, TransportFactory, TunConfig, TunDevice};
+use data_encoding::BASE64;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
+use tokio::sync::Notify;
+
+static HANDLE_SEQ: AtomicU64 = AtomicU64::new(5000);
+
+/// Shared, observable state of a native F5 connection.
+#[derive(Default)]
+struct Shared {
+ alive: bool,
+ handle: Option,
+ /// Cookie header + host captured during the handshake, needed for the
+ /// HTTP logout during teardown.
+ logout_cookie: Option,
+ /// Negotiated DNS servers (dotted), exposed via [`NativeF5Backend::negotiated_dns`]
+ /// so callers can resolve VPN-only names through the tunnel.
+ dns: Vec,
+ /// Host mutations made by the TUN's `configure`, exposed via
+ /// [`NativeF5Backend::teardown_plan`] so the CLI can persist them for an
+ /// out-of-process `akon vpn off`.
+ teardown_plan: crate::vpn::f5::HostTeardownPlan,
+}
+
+/// Native, pure-Rust F5 BIG-IP SSL VPN backend.
+///
+/// Replaces the openconnect delegation for the F5 protocol. The transport is
+/// injected (a TLS socket in production; the in-memory duplex in tests), which
+/// is what makes the whole flow validatable by the test actors framework. A
+/// [`TunDevice`] seam carries the user data plane (a real `/dev/net/tun` in
+/// production; a fake in tests).
+pub struct NativeF5Backend {
+ transport: Option>,
+ /// Optional connection factory for the HTTP (auth/config) phase. When set,
+ /// the handshake reconnects per request as the server closes connections
+ /// (real F5 behaviour). When `None`, the single `transport` is reused for
+ /// the whole exchange (in-memory test transport never closes mid-exchange).
+ factory: Option>,
+ tun: Option>,
+ dns: Option>,
+ host: String,
+ shared: Arc>,
+ /// Signalled by [`disconnect`] to stop the data-plane pump and trigger
+ /// graceful teardown.
+ shutdown: Arc,
+}
+
+impl NativeF5Backend {
+ /// Create a backend over `transport` for `host`, with a no-op TUN device.
+ ///
+ /// The no-op TUN lets the full control plane (auth → config → tunnel → PPP →
+ /// teardown) be tested without root; the data-plane pump runs but moves no
+ /// real OS packets. Use [`with_transport_and_tun`](Self::with_transport_and_tun)
+ /// to attach a real or fake TUN that actually carries packets.
+ pub fn with_transport(transport: Box, host: impl Into) -> Self {
+ Self::with_transport_and_tun(transport, Box::new(NoopTun::default()), host)
+ }
+
+ /// Create a backend over `transport` and `tun` for `host` (no-op DNS).
+ pub fn with_transport_and_tun(
+ transport: Box,
+ tun: Box,
+ host: impl Into,
+ ) -> Self {
+ Self::with_parts(transport, tun, Box::new(NoopDns), host)
+ }
+
+ /// Create a backend with explicit transport, TUN, and DNS applier.
+ pub fn with_parts(
+ transport: Box,
+ tun: Box,
+ dns: Box,
+ host: impl Into,
+ ) -> Self {
+ Self {
+ transport: Some(transport),
+ factory: None,
+ tun: Some(tun),
+ dns: Some(dns),
+ host: host.into(),
+ shared: Arc::new(Mutex::new(Shared::default())),
+ shutdown: Arc::new(Notify::new()),
+ }
+ }
+
+ /// Create a backend whose HTTP (auth/config) phase reconnects via `factory`
+ /// (so it survives servers that close the connection between requests), with
+ /// the given TUN and DNS appliers.
+ pub fn with_factory_and_parts(
+ factory: Box,
+ tun: Box,
+ dns: Box,
+ host: impl Into,
+ ) -> Self {
+ Self {
+ transport: None,
+ factory: Some(factory),
+ tun: Some(tun),
+ dns: Some(dns),
+ host: host.into(),
+ shared: Arc::new(Mutex::new(Shared::default())),
+ shutdown: Arc::new(Notify::new()),
+ }
+ }
+
+ /// Build a production backend from a [`VpnConfig`]: connect a real TLS
+ /// transport to the configured server (default port 443) and attach a real
+ /// Linux TUN device. Linux-only; requires `CAP_NET_ADMIN` for the TUN.
+ ///
+ /// This is the constructor the CLI uses for `protocol = f5`.
+ #[cfg(target_os = "linux")]
+ pub async fn connect_from_config(
+ config: &crate::config::VpnConfig,
+ ) -> Result {
+ use crate::vpn::f5::tls_transport::TlsTransportFactory;
+ use crate::vpn::f5::tun::LinuxTun;
+
+ // Split "host" or "host:port" from the configured server.
+ let (host, port) = split_host_port(&config.server, 443);
+
+ // Validate connectivity eagerly so the caller gets an immediate error on
+ // an unreachable/bad server; the handshake itself reconnects via factory.
+ {
+ use crate::vpn::f5::tls_transport::TlsTransport;
+ let _probe = TlsTransport::connect(&host, port).await.map_err(|e| {
+ BackendError::StartFailed(format!("TLS connect to {host}:{port}: {e}"))
+ })?;
+ }
+
+ let factory = TlsTransportFactory::new(host.clone(), port);
+
+ let tun = LinuxTun::open("")
+ .map_err(|e| BackendError::StartFailed(format!("open TUN device: {e}")))?;
+
+ let dns = crate::vpn::f5::dns::SystemDnsApplier::detect();
+
+ Ok(Self::with_factory_and_parts(
+ Box::new(factory),
+ Box::new(tun),
+ Box::new(dns),
+ host,
+ ))
+ }
+
+ /// Build a **control-plane-only** backend from a [`VpnConfig`]: connect a
+ /// real TLS transport to the configured server, but attach a **no-op TUN and
+ /// no-op DNS** so the full handshake (auth → config → tunnel upgrade → PPP →
+ /// `Connected`) is validated against the real appliance **without taking over
+ /// the host's networking** (no TUN device created, no routes, no DNS changes).
+ ///
+ /// This is the minimal, low-footprint path used by the production sign-off
+ /// test: it proves end-to-end reachability and protocol correctness against
+ /// the live server while leaving the developer's connectivity untouched. It
+ /// needs no `CAP_NET_ADMIN` because it creates no TUN device.
+ pub async fn connect_control_plane_only(
+ config: &crate::config::VpnConfig,
+ ) -> Result {
+ use crate::vpn::f5::tls_transport::{TlsTransport, TlsTransportFactory};
+ use crate::vpn::transport::NoopTun;
+
+ let (host, port) = split_host_port(&config.server, 443);
+
+ // Eager connectivity probe for a fast, clear error on an unreachable host.
+ {
+ let _probe = TlsTransport::connect(&host, port).await.map_err(|e| {
+ BackendError::StartFailed(format!("TLS connect to {host}:{port}: {e}"))
+ })?;
+ }
+
+ let factory = TlsTransportFactory::new(host.clone(), port);
+
+ Ok(Self::with_factory_and_parts(
+ Box::new(factory),
+ Box::new(NoopTun::default()),
+ Box::new(NoopDns),
+ host,
+ ))
+ }
+
+ /// The DNS servers negotiated for the tunnel (dotted IPv4), available after
+ /// the connection reaches `Connected`. Lets callers resolve VPN-only names
+ /// through the tunnel.
+ pub fn negotiated_dns(&self) -> Vec {
+ self.shared.lock().expect("poisoned").dns.clone()
+ }
+
+ /// The host-teardown plan recording every networking mutation made to bring
+ /// up the tunnel (tun device, server-pin route, rp_filter originals, DNS
+ /// interface). Available once the connection reaches `Connected`. Persist it
+ /// (e.g. to the VPN state file) so `akon vpn off` can fully restore the host
+ /// even if this process is later killed. See
+ /// [`crate::vpn::f5::teardown::teardown_host`].
+ pub fn teardown_plan(&self) -> crate::vpn::f5::HostTeardownPlan {
+ self.shared.lock().expect("poisoned").teardown_plan.clone()
+ }
+}
+
+/// Resolve a host (or `host:port`) to its first IPv4 address (dotted string).
+/// Returns `None` if it can't be resolved.
+fn resolve_host_ipv4(host: &str) -> Option {
+ use std::net::ToSocketAddrs;
+ let (h, _) = split_host_port(host, 443);
+ if let Ok(ip) = h.parse::() {
+ return Some(ip.to_string());
+ }
+ (h.as_str(), 443u16)
+ .to_socket_addrs()
+ .ok()?
+ .find_map(|sa| match sa.ip() {
+ std::net::IpAddr::V4(v4) => Some(v4.to_string()),
+ _ => None,
+ })
+}
+
+/// Split a `host` or `host:port` string, applying `default_port` when absent.
+fn split_host_port(server: &str, default_port: u16) -> (String, u16) {
+ // Strip a leading scheme if present.
+ let s = server
+ .strip_prefix("https://")
+ .or_else(|| server.strip_prefix("http://"))
+ .unwrap_or(server);
+ // Strip any trailing path.
+ let s = s.split('/').next().unwrap_or(s);
+ if let Some((h, p)) = s.rsplit_once(':') {
+ if let Ok(port) = p.parse::() {
+ return (h.to_string(), port);
+ }
+ }
+ (s.to_string(), default_port)
+}
+
+impl VpnBackend for NativeF5Backend {
+ fn connect(
+ &mut self,
+ credentials: Credentials,
+ ) -> Result, BackendError> {
+ let initial_transport = self.transport.take();
+ let factory = self.factory.take();
+ if initial_transport.is_none() && factory.is_none() {
+ return Err(BackendError::StartFailed(
+ "no transport or factory available".into(),
+ ));
+ }
+ let mut tun = self
+ .tun
+ .take()
+ .ok_or_else(|| BackendError::StartFailed("tun already consumed".into()))?;
+ let mut dns = self
+ .dns
+ .take()
+ .ok_or_else(|| BackendError::StartFailed("dns already consumed".into()))?;
+ let host = self.host.clone();
+ // The actual OS interface name (kernel-assigned for the real TUN).
+ let device = tun.name();
+ let shared = Arc::clone(&self.shared);
+ let shutdown = Arc::clone(&self.shutdown);
+ let (tx, rx) = mpsc::unbounded_channel();
+
+ tokio::spawn(async move {
+ let _ = tx.send(LifecycleEvent::Connecting);
+
+ // The HTTP phase uses a connection manager that reconnects when the
+ // server closes the connection between requests (real F5 behaviour).
+ let mut conn = HttpConn::new(initial_transport, factory);
+
+ // Bound only the handshake so a misbehaving peer can't hang setup.
+ let handshake = tokio::time::timeout(
+ Duration::from_secs(20),
+ run_handshake(&mut conn, &host, &device, &credentials, &tx, &shared),
+ )
+ .await;
+
+ let session = match handshake {
+ Ok(Ok(session)) => session,
+ Ok(Err(e)) => {
+ let _ = tx.send(failure_event(&e));
+ shared.lock().expect("poisoned").alive = false;
+ return;
+ }
+ Err(_) => {
+ let _ = tx.send(LifecycleEvent::Failed {
+ kind: FailureKind::Network,
+ detail: "handshake timed out".into(),
+ });
+ shared.lock().expect("poisoned").alive = false;
+ return;
+ }
+ };
+
+ // The tunnel transport is the connection left open after `/myvpn`.
+ let mut transport = match conn.into_transport() {
+ Some(t) => t,
+ None => {
+ let _ = tx.send(LifecycleEvent::Failed {
+ kind: FailureKind::Network,
+ detail: "no tunnel transport after handshake".into(),
+ });
+ shared.lock().expect("poisoned").alive = false;
+ return;
+ }
+ };
+
+ // --- Data plane: pump packets until disconnect or transport EOF ---
+ run_data_plane(
+ transport.as_mut(),
+ tun.as_mut(),
+ dns.as_mut(),
+ &session,
+ &tx,
+ &shared,
+ &shutdown,
+ )
+ .await;
+
+ // --- Teardown: PPP Terminate-Request + HTTP logout + close ---
+ graceful_teardown(transport.as_mut(), &host, &session).await;
+
+ shared.lock().expect("poisoned").alive = false;
+ let _ = tx.send(LifecycleEvent::Disconnected {
+ reason: crate::vpn::backend::DisconnectReason::UserRequested,
+ });
+ });
+
+ Ok(rx)
+ }
+
+ fn disconnect(&mut self) -> Result<(), BackendError> {
+ // Signal the running session to stop pumping and tear down gracefully.
+ self.shutdown.notify_waiters();
+ // Reflect intent immediately for observers; the session task clears the
+ // handle once teardown completes.
+ self.shared.lock().expect("poisoned").alive = false;
+ Ok(())
+ }
+
+ fn is_alive(&self) -> bool {
+ self.shared.lock().expect("poisoned").alive
+ }
+
+ fn handle(&self) -> Option {
+ self.shared.lock().expect("poisoned").handle
+ }
+}
+
+/// Map an [`F5Error`] to a terminal lifecycle failure.
+fn failure_event(e: &F5Error) -> LifecycleEvent {
+ let kind = match e {
+ F5Error::AuthFailed(_) => FailureKind::Authentication,
+ F5Error::TunnelUpgradeRejected(_)
+ | F5Error::MalformedHttp(_)
+ | F5Error::BadEncapMagic(_)
+ | F5Error::TruncatedFrame { .. }
+ | F5Error::HdlcFcsInvalid
+ | F5Error::MalformedPpp(_) => FailureKind::Network,
+ F5Error::InvalidConfig(_) => FailureKind::Backend,
+ };
+ LifecycleEvent::Failed {
+ kind,
+ detail: e.to_string(),
+ }
+}
+
+/// Manages the HTTP-phase connection, reconnecting when the server closes it
+/// between requests (real F5 frontends do this routinely).
+///
+/// `request` sends one HTTP request, transparently (re)connecting first if no
+/// live connection is held. If the response says the server will close
+/// (`wants_close`), the current connection is dropped so the next request opens
+/// a fresh one. The connection that survives the final `/myvpn` request is the
+/// tunnel transport, retrieved via [`HttpConn::into_transport`].
+struct HttpConn {
+ current: Option>,
+ factory: Option>,
+}
+
+impl HttpConn {
+ fn new(
+ initial: Option>,
+ factory: Option>,
+ ) -> Self {
+ Self {
+ current: initial,
+ factory,
+ }
+ }
+
+ /// Ensure a live connection exists (reconnecting via the factory if needed).
+ async fn ensure_connected(&mut self) -> Result<(), F5Error> {
+ if self.current.is_some() {
+ return Ok(());
+ }
+ let factory = self.factory.as_ref().ok_or_else(|| {
+ F5Error::MalformedHttp("connection closed, no factory to reconnect".into())
+ })?;
+ let t = factory
+ .connect()
+ .await
+ .map_err(|e| F5Error::MalformedHttp(format!("reconnect failed: {e}")))?;
+ self.current = Some(t);
+ Ok(())
+ }
+
+ /// Send a request, (re)connecting as needed and dropping the connection when
+ /// the server signals close.
+ async fn request(&mut self, req: &HttpRequest<'_>) -> Result {
+ self.ensure_connected().await?;
+ let transport = self.current.as_mut().expect("connected");
+ let result = send_request(transport.as_mut(), req).await;
+
+ match result {
+ Ok(resp) => {
+ if resp.wants_close {
+ // Drop the connection; next request reconnects.
+ self.current = None;
+ }
+ Ok(resp)
+ }
+ Err(e) => {
+ // The connection is unusable; drop it so a retry can reconnect.
+ self.current = None;
+ Err(e)
+ }
+ }
+ }
+
+ /// Take the open connection (the tunnel transport after `/myvpn`).
+ fn into_transport(self) -> Option> {
+ self.current
+ }
+}
+
+/// Run the F5 HTML-form authentication loop until both session cookies appear.
+///
+/// Mirrors openconnect `f5_obtain_cookie`: GET the login page, parse the
+/// `
` (the first must be `auth_form`), fill username + password (akon's
+/// pre-composed PIN+OTP — a single string that satisfies the common single-step
+/// F5 login), POST `application/x-www-form-urlencoded` to the form action,
+/// follow it, and re-check for `MRHSession` + `F5_ST`. Supports multi-step
+/// servers (a second form gets the same submission). Bounded iterations so a
+/// misbehaving server cannot loop forever.
+async fn authenticate(
+ conn: &mut HttpConn,
+ host: &str,
+ credentials: &Credentials,
+) -> Result {
+ let mut jar = F5CookieJar::new();
+ // Current request path (no leading-slash assumptions; we keep it as a full
+ // request-target string starting with `/`).
+ let mut next_path = "/".to_string();
+ let mut pending_post: Option = None;
+ // Number of HTML forms we have actually parsed (openconnect's form_order).
+ let mut form_order = 0u32;
+
+ // Generous bound: redirect chains + multi-step auth still terminate.
+ for _step in 0..16 {
+ // Build the request. Echo ALL accumulated cookies on every request
+ // (openconnect re-sends the full Cookie header each time).
+ let cookie_header = jar.cookie_header_all();
+ let resp = if let Some(body) = pending_post.take() {
+ let mut req = HttpRequest::post_form(&next_path, host, body);
+ if let Some(ch) = &cookie_header {
+ req = req.with_header("Cookie", ch);
+ }
+ conn.request(&req).await?
+ } else {
+ let mut req = HttpRequest::get(&next_path, host);
+ if let Some(ch) = &cookie_header {
+ req = req.with_header("Cookie", ch);
+ }
+ conn.request(&req).await?
+ };
+
+ // Harvest cookies from this response.
+ for sc in resp.header_all("set-cookie") {
+ jar.ingest_set_cookie(sc);
+ }
+
+ // Success = both MRHSession and F5_ST present.
+ if jar.is_authenticated() {
+ return jar
+ .cookie_header()
+ .ok_or_else(|| F5Error::AuthFailed("inconsistent cookie state".into()));
+ }
+
+ // Redirect: openconnect follows ANY non-200 response that carries a
+ // Location header, converting the method to GET (HTTP_REDIRECT_TO_GET).
+ if resp.status != 200 {
+ if let Some(location) = resp.header("location") {
+ next_path = resolve_target(&next_path, location);
+ pending_post = None; // POST -> GET on redirect
+ continue;
+ }
+ }
+
+ // Otherwise parse the next form and submit it.
+ let html = String::from_utf8_lossy(&resp.body);
+ let form = match F5AuthForm::parse(&html) {
+ Some(f) => f,
+ None => {
+ return Err(F5Error::AuthFailed(format!(
+ "no login form found (HTTP {}); the server may present a SAML/JS login \
+ not yet supported, or credentials are wrong",
+ resp.status
+ )));
+ }
+ };
+ form_order += 1;
+
+ // openconnect: the FIRST parsed form must be `auth_form`.
+ if form_order == 1 && !form.id.is_empty() && form.id != "auth_form" {
+ return Err(F5Error::AuthFailed(format!(
+ "unexpected first form id '{}' (expected 'auth_form') — likely not an F5 VPN",
+ form.id
+ )));
+ }
+
+ let body = form.build_submission(&credentials.username, &credentials.password);
+ // POST to the form action (resolved against the current path), or the
+ // same path if the form has no action.
+ next_path = if form.action.is_empty() {
+ next_path.clone()
+ } else {
+ resolve_target(&next_path, &form.action)
+ };
+ pending_post = Some(body);
+ }
+
+ Err(F5Error::AuthFailed(
+ "authentication did not complete (no MRHSession/F5_ST after multiple steps)".into(),
+ ))
+}
+
+/// Resolve a redirect/form-action `location` against the `current` request path
+/// into a new request target (path + query). Mirrors openconnect's
+/// `handle_redirect`:
+/// - absolute `https://host/path` → the `/path` portion (same-host assumption;
+/// a different host would need a reconnect, handled by the factory),
+/// - absolute path `/foo` → used as-is,
+/// - relative `foo` → resolved against the directory of the current path.
+fn resolve_target(current: &str, location: &str) -> String {
+ let loc = location.trim();
+ if loc.is_empty() || loc.starts_with('#') {
+ return current.to_string();
+ }
+
+ // Absolute URL: take the path component (drop scheme + authority).
+ if let Some(rest) = loc
+ .strip_prefix("https://")
+ .or_else(|| loc.strip_prefix("http://"))
+ {
+ return match rest.find('/') {
+ Some(i) => rest[i..].to_string(),
+ None => "/".to_string(),
+ };
+ }
+
+ // Absolute path.
+ if loc.starts_with('/') {
+ return loc.to_string();
+ }
+
+ // Relative path: resolve against the directory of the current path.
+ // Strip the current query string first.
+ let current_path = current.split('?').next().unwrap_or("/");
+ match current_path.rfind('/') {
+ Some(i) => format!("{}/{}", ¤t_path[..i], loc),
+ None => format!("/{loc}"),
+ }
+}
+
+/// The negotiated session state needed for the data plane and teardown.
+struct Session {
+ /// `Cookie` header value (`MRHSession=..; F5_ST=..`) for the logout request.
+ cookie_header: String,
+ /// PPP magic number (for LCP terminate framing).
+ #[allow(dead_code)]
+ magic: u32,
+ /// The tunnel interface name (e.g. `tun0`).
+ device: String,
+ /// The assigned tunnel IP, parsed (for the `Connected`/`LinkUp` events).
+ parsed_ip: std::net::IpAddr,
+ /// Negotiated TUN configuration.
+ tun_config: TunConfig,
+}
+
+/// Run the F5 control-plane handshake, emitting lifecycle events and returning
+/// the [`Session`] needed to run the data plane and tear down.
+async fn run_handshake(
+ conn: &mut HttpConn,
+ host: &str,
+ device: &str,
+ credentials: &Credentials,
+ tx: &UnboundedSender,
+ shared: &Arc>,
+) -> Result {
+ // --- 1. Authenticate ---
+ let _ = tx.send(LifecycleEvent::Authenticating);
+ let cookie_header = authenticate(conn, host, credentials).await?;
+ let _ = tx.send(LifecycleEvent::SessionEstablished);
+
+ // --- 2. Fetch config ---
+ let profile_resp = conn
+ .request(
+ &HttpRequest::get("/vdesk/vpn/index.php3?outform=xml&client_version=2.0", host)
+ .with_header("Cookie", &cookie_header),
+ )
+ .await?;
+ let params = parse_profile(&String::from_utf8_lossy(&profile_resp.body))?;
+
+ let options_path = format!(
+ "/vdesk/vpn/connect.php3?{}&outform=xml&client_version=2.0",
+ params
+ );
+ let options_resp = conn
+ .request(&HttpRequest::get(&options_path, host).with_header("Cookie", &cookie_header))
+ .await?;
+ let opts = parse_options(&String::from_utf8_lossy(&options_resp.body))?;
+
+ // --- 3. Tunnel upgrade (no Cookie; auth via sess+Z query params) ---
+ // This connection must stay OPEN for PPP, so we ensure a live connection and
+ // send the request directly (not through `request`, which may drop on close).
+ let myvpn = build_myvpn_path(&opts);
+ conn.ensure_connected().await?;
+ let transport = conn.current.as_mut().expect("connected for /myvpn");
+ let upgrade = send_request(transport.as_mut(), &HttpRequest::get(&myvpn, host)).await?;
+ if upgrade.status != 200 && upgrade.status != 201 {
+ return Err(F5Error::TunnelUpgradeRejected(upgrade.status));
+ }
+ let assigned_ip = upgrade
+ .header("x-vpn-client-ip")
+ .map(|s| s.to_string())
+ .unwrap_or_default();
+
+ // --- 4. PPP negotiation to network up (over the now-open tunnel transport) ---
+ let device = device.to_string();
+ let negotiator = run_ppp(transport.as_mut(), &upgrade.leftover).await?;
+
+ // Resolve the final IP: prefer the PPP-negotiated address; fall back to the
+ // header-assigned one.
+ let ip = negotiator
+ .negotiated_ipv4()
+ .map(|o| std::net::Ipv4Addr::from(o).to_string())
+ .or(if assigned_ip.is_empty() {
+ None
+ } else {
+ Some(assigned_ip.clone())
+ })
+ .unwrap_or_else(|| "0.0.0.0".to_string());
+
+ let parsed_ip = ip
+ .parse()
+ .unwrap_or_else(|_| "0.0.0.0".parse().expect("valid"));
+
+ // Build the TUN configuration from what was negotiated.
+ // Resolve the VPN server to an IP so full-tunnel mode can pin its packets to
+ // the original gateway (keeping the encrypted tunnel off the tunnel).
+ let server_ip = resolve_host_ipv4(host);
+
+ let tun_config = TunConfig {
+ ipv4: Some(ip.clone()),
+ // Derive the MTU from the negotiated MRU (was a fixed 1400).
+ mtu: Some(negotiator.negotiated_mtu()),
+ dns: negotiator
+ .dns_servers()
+ .into_iter()
+ .map(|o| std::net::Ipv4Addr::from(o).to_string())
+ .collect(),
+ domains: opts.domains.clone(),
+ routes: opts.routes.clone(),
+ default_gateway: opts.default_gateway,
+ server_ip,
+ };
+
+ {
+ let mut g = shared.lock().expect("poisoned");
+ g.alive = true;
+ g.handle = Some(ConnectionHandle(HANDLE_SEQ.fetch_add(1, Ordering::SeqCst)));
+ g.logout_cookie = Some(cookie_header.clone());
+ g.dns = tun_config.dns.clone();
+ }
+
+ // NOTE: `LinkUp`/`Connected` are intentionally NOT emitted here. The
+ // handshake only proves the control plane + PPP negotiation succeeded; the
+ // OS interface is not configured and no packets flow yet. Emitting
+ // `Connected` now would lie to the user when `configure()` later fails (the
+ // production "looks connected but everything hangs" bug). These events are
+ // emitted from `run_data_plane` once the TUN is actually configured.
+ Ok(Session {
+ cookie_header,
+ magic: negotiator.magic(),
+ device,
+ parsed_ip,
+ tun_config,
+ })
+}
+
+/// The bidirectional data-plane pump. Runs until [`disconnect`](NativeF5Backend::disconnect)
+/// signals `shutdown`, the transport closes, or the TUN device closes.
+///
+/// - OS → tunnel: read an IP packet from the TUN device, F5-encapsulate it, send
+/// it over the transport.
+/// - tunnel → OS: read from the transport, F5-decapsulate, and write each IP
+/// packet to the TUN device (ignoring any residual PPP control frames).
+async fn run_data_plane(
+ transport: &mut dyn Transport,
+ tun: &mut dyn TunDevice,
+ dns: &mut dyn DnsApplier,
+ session: &Session,
+ tx: &UnboundedSender,
+ shared: &Arc>,
+ shutdown: &Arc,
+) {
+ // Configure the OS interface with the negotiated parameters. This is the
+ // step that actually makes the tunnel usable (address, MTU, routes). If it
+ // fails we must NOT pretend to be connected: surface a `Failed` event so
+ // the supervisor/CLI reacts, instead of silently leaving a dead tunnel
+ // (the production "looks connected but everything hangs" bug).
+ if let Err(e) = tun.configure(&session.tun_config).await {
+ eprintln!("[tun-cfg] ERROR: interface configuration failed: {e}");
+ let _ = tx.send(LifecycleEvent::Failed {
+ kind: FailureKind::Network,
+ detail: format!("failed to configure tunnel interface: {e}"),
+ });
+ return;
+ }
+
+ // Capture the host-teardown plan now that `configure` has recorded the
+ // link/route/rp_filter mutations, so the CLI can persist it for an
+ // out-of-process `akon vpn off` (works even if this process is SIGKILL'd).
+ let mut plan = tun.teardown_plan();
+
+ // Apply the negotiated DNS servers/search domains to the host resolver
+ // (systemd-resolved on Fedora/Ubuntu, with fallbacks). Log failures — a
+ // working data plane is useless if names don't resolve via the VPN DNS.
+ // Only record a DNS-revert in the teardown plan when the applier ACTUALLY
+ // mutates the host resolver (the real SystemDnsApplier) AND the apply
+ // succeeded — so a NoopDns / test / container run never schedules a
+ // `resolvectl` call against the un-namespaced host resolver.
+ if !session.tun_config.dns.is_empty() {
+ match dns.apply(&session.device, &session.tun_config) {
+ Ok(()) => {
+ eprintln!(
+ "[dns] applied: servers={:?} domains={:?} on {}",
+ session.tun_config.dns, session.tun_config.domains, session.device
+ );
+ if dns.mutates_host() {
+ plan.dns_iface = Some(session.device.clone());
+ }
+ }
+ Err(e) => {
+ eprintln!("[dns] WARNING: failed to apply VPN DNS: {e} — names may not resolve")
+ }
+ }
+ }
+
+ // Publish the finalized plan (now including DNS revert if applicable).
+ {
+ let mut g = shared.lock().expect("poisoned");
+ g.teardown_plan = plan;
+ }
+
+ // The OS interface is now configured and packets can flow: announce
+ // `LinkUp` then `Connected`. This is the first point at which the tunnel is
+ // genuinely usable.
+ let _ = tx.send(LifecycleEvent::LinkUp {
+ ip: session.parsed_ip,
+ device: session.device.clone(),
+ });
+ let _ = tx.send(LifecycleEvent::Connected {
+ ip: session.parsed_ip,
+ device: session.device.clone(),
+ });
+
+ // Run the pump until it exits, then always revert DNS.
+ pump_packets(transport, tun, shutdown).await;
+ let _ = dns.revert(&session.device);
+}
+
+/// The inner packet-forwarding loop (separated so DNS revert always runs on exit).
+async fn pump_packets(
+ transport: &mut dyn Transport,
+ tun: &mut dyn TunDevice,
+ shutdown: &Arc,
+) {
+ let mut tun_buf = vec![0u8; 4096];
+ let mut net_buf = vec![0u8; 4096];
+ let debug = crate::vpn::f5::http::debug_enabled();
+ let (mut out_pkts, mut in_pkts) = (0u64, 0u64);
+
+ loop {
+ tokio::select! {
+ _ = shutdown.notified() => return,
+
+ // OS -> tunnel
+ r = tun.read_packet(&mut tun_buf) => {
+ match r {
+ Ok(0) | Err(_) => return,
+ Ok(n) => {
+ out_pkts += 1;
+ if debug {
+ eprintln!(
+ "[f5-data] OS->tun #{out_pkts}: {n} bytes {}",
+ hex_preview(&tun_buf[..n], 20)
+ );
+ }
+ let ppp_frame = wrap_ip_in_ppp(&tun_buf[..n]);
+ let wire = f5_encap(&ppp_frame);
+ if transport.send(&wire).await.is_err() {
+ return;
+ }
+ }
+ }
+ }
+
+ // tunnel -> OS
+ r = transport.recv(&mut net_buf) => {
+ match r {
+ Ok(0) | Err(_) => return,
+ Ok(n) => {
+ if let Ok(frames) = f5_decap(&net_buf[..n]) {
+ for ppp in frames {
+ // Strip the PPP header and forward only IP packets;
+ // residual LCP/IPCP control frames are ignored.
+ if let Some(ip_packet) = ppp_payload_if_ip(&ppp) {
+ in_pkts += 1;
+ if debug {
+ eprintln!(
+ "[f5-data] tun<-net #{in_pkts}: {} bytes {}",
+ ip_packet.len(),
+ hex_preview(ip_packet, 20)
+ );
+ }
+ let _ = tun.write_packet(ip_packet).await;
+ } else if debug {
+ eprintln!(
+ "[f5-data] tun<-net non-IP ctrl frame: {}",
+ hex_preview(&ppp, 16)
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Wrap a raw IP packet in a PPP IP frame (`FF 03` + proto + payload). Selects
+/// IPv6 proto (0x57) when the first nibble is 6, else IPv4 (0x21), matching
+/// openconnect's `ppp.c` send path.
+fn wrap_ip_in_ppp(ip_packet: &[u8]) -> Vec {
+ let proto: u16 = if ip_packet.first().map(|b| b >> 4) == Some(6) {
+ 0x0057
+ } else {
+ 0x0021
+ };
+ let mut frame = Vec::with_capacity(ip_packet.len() + 4);
+ frame.push(0xff);
+ frame.push(0x03);
+ frame.extend_from_slice(&proto.to_be_bytes());
+ frame.extend_from_slice(ip_packet);
+ frame
+}
+
+/// If a PPP frame carries an IP (0x21) or IPv6 (0x57) payload, return the inner
+/// IP packet (after the `FF 03 proto` header). Otherwise `None` (control frame).
+fn ppp_payload_if_ip(frame: &[u8]) -> Option<&[u8]> {
+ // Tolerate optional FF 03 prefix.
+ let rest = if frame.len() >= 2 && frame[0] == 0xff && frame[1] == 0x03 {
+ &frame[2..]
+ } else {
+ frame
+ };
+ // Protocol is 1 byte if the low bit is set (PFC), else 2 bytes.
+ if rest.is_empty() {
+ return None;
+ }
+ let (proto, payload) = if rest[0] & 0x01 == 1 {
+ (rest[0] as u16, &rest[1..])
+ } else if rest.len() >= 2 {
+ (u16::from_be_bytes([rest[0], rest[1]]), &rest[2..])
+ } else {
+ return None;
+ };
+ match proto {
+ 0x0021 | 0x0057 => Some(payload), // IPv4 / IPv6
+ _ => None,
+ }
+}
+
+/// Gracefully tear down the session: send an LCP Terminate-Request, then the F5
+/// HTTP logout, then close the transport. Best-effort and idempotent — failures
+/// are ignored since we are shutting down anyway.
+async fn graceful_teardown(transport: &mut dyn Transport, host: &str, session: &Session) {
+ // 1. PPP LCP Terminate-Request.
+ let term = lcp_terminate_request(0xfe);
+ let wire = f5_encap(&crate::vpn::f5::ppp::build_ncp_frame(&term));
+ let _ = transport.send(&wire).await;
+
+ // 2. F5 HTTP logout (best-effort, short timeout so teardown can't hang).
+ let logout = HttpRequest::get("/vdesk/hangup.php3?hangup_error=1", host)
+ .with_header("Cookie", &session.cookie_header);
+ let _ = tokio::time::timeout(Duration::from_secs(3), send_request(transport, &logout)).await;
+
+ // 3. Close the transport.
+ let _ = transport.close().await;
+}
+
+/// Build the `/myvpn` tunnel-upgrade path. No cookies; auth via `sess` + `Z`.
+fn build_myvpn_path(opts: &F5Options) -> String {
+ let sid = opts.session_id.clone().unwrap_or_default();
+ let urz = opts.ur_z.clone().unwrap_or_default();
+ let hostname_b64 = BASE64.encode(b"localhost");
+ format!(
+ "/myvpn?sess={}&hdlc_framing={}&ipv4={}&ipv6={}&Z={}&hostname={}",
+ sid,
+ if opts.hdlc_framing { "yes" } else { "no" },
+ if opts.ipv4 { "yes" } else { "no" },
+ if opts.ipv6 { "yes" } else { "no" },
+ urz,
+ hostname_b64,
+ )
+}
+
+/// Run PPP LCP+IPCP negotiation over the (now raw) transport until "network up",
+/// returning the negotiator (carrying the negotiated IP/DNS/magic).
+///
+/// `prebuffered` carries any bytes the server coalesced after the `/myvpn`
+/// response (the start of the PPP stream on a real TLS connection); they are
+/// processed before reading more from the transport.
+async fn run_ppp(
+ transport: &mut dyn Transport,
+ prebuffered: &[u8],
+) -> Result {
+ let mut negotiator = PppNegotiator::new();
+
+ // Send the initial LCP Config-Request(s).
+ for frame in negotiator.start() {
+ let wire = f5_encap(&frame);
+ send_all(transport, &wire).await?;
+ }
+
+ // Process any pre-buffered PPP bytes first.
+ if !prebuffered.is_empty() {
+ drive_ppp_bytes(transport, &mut negotiator, prebuffered).await?;
+ }
+
+ let mut buf = [0u8; 4096];
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
+
+ loop {
+ if matches!(negotiator.phase(), PppPhase::Up) {
+ return Ok(negotiator);
+ }
+ if matches!(negotiator.phase(), PppPhase::Terminated) {
+ return Err(F5Error::MalformedPpp("PPP terminated during setup".into()));
+ }
+
+ let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
+ if remaining.is_zero() {
+ return Err(F5Error::MalformedPpp("PPP negotiation timed out".into()));
+ }
+
+ let n = match tokio::time::timeout(remaining, transport.recv(&mut buf)).await {
+ Ok(Ok(0)) => return Err(F5Error::MalformedPpp("transport closed during PPP".into())),
+ Ok(Ok(n)) => n,
+ Ok(Err(e)) => return Err(F5Error::MalformedHttp(format!("recv: {}", e))),
+ Err(_) => return Err(F5Error::MalformedPpp("PPP negotiation timed out".into())),
+ };
+
+ drive_ppp_bytes(transport, &mut negotiator, &buf[..n]).await?;
+ }
+}
+
+/// Decode F5 frames from `bytes` and feed each through the negotiator, sending
+/// any replies it produces.
+///
+/// Tolerant by design (matching openconnect): a frame that fails to decap or
+/// parse is logged and skipped rather than failing the whole session. Only a
+/// genuinely fatal transport condition aborts PPP.
+async fn drive_ppp_bytes(
+ transport: &mut dyn Transport,
+ negotiator: &mut PppNegotiator,
+ bytes: &[u8],
+) -> Result<(), F5Error> {
+ if crate::vpn::f5::http::debug_enabled() {
+ eprintln!(
+ "[f5-ppp] <<< {} raw bytes: {}",
+ bytes.len(),
+ hex_preview(bytes, 64)
+ );
+ }
+
+ let frames = match f5_decap(bytes) {
+ Ok(f) => f,
+ Err(e) => {
+ if crate::vpn::f5::http::debug_enabled() {
+ eprintln!("[f5-ppp] decap error (skipping): {e}");
+ }
+ return Ok(());
+ }
+ };
+
+ for ppp_frame in frames {
+ if crate::vpn::f5::http::debug_enabled() {
+ eprintln!(
+ "[f5-ppp] frame {} bytes: {}",
+ ppp_frame.len(),
+ hex_preview(&ppp_frame, 48)
+ );
+ }
+ match negotiator.on_frame(&ppp_frame) {
+ Ok(replies) => {
+ for reply in replies {
+ let wire = f5_encap(&reply);
+ send_all(transport, &wire).await?;
+ }
+ }
+ Err(e) => {
+ // A single unparseable frame must not kill the session.
+ if crate::vpn::f5::http::debug_enabled() {
+ eprintln!("[f5-ppp] frame parse error (skipping): {e}");
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Render up to `max` bytes of `data` as space-separated hex for diagnostics.
+fn hex_preview(data: &[u8], max: usize) -> String {
+ let shown = data.len().min(max);
+ let mut s: String = data[..shown].iter().map(|b| format!("{b:02x} ")).collect();
+ if data.len() > max {
+ s.push_str(&format!("... (+{} more)", data.len() - max));
+ }
+ s.trim_end().to_string()
+}
+
+async fn send_all(transport: &mut dyn Transport, data: &[u8]) -> Result<(), F5Error> {
+ transport
+ .send(data)
+ .await
+ .map_err(|e| F5Error::MalformedHttp(format!("send: {}", e)))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::vpn::f5::config::F5Options;
+
+ #[test]
+ fn myvpn_path_has_required_params_and_no_cookie_semantics() {
+ let opts = F5Options {
+ session_id: Some("SID".into()),
+ ur_z: Some("URZ".into()),
+ ipv4: true,
+ ipv6: false,
+ hdlc_framing: false,
+ ..Default::default()
+ };
+ let path = build_myvpn_path(&opts);
+ assert!(path.contains("sess=SID"));
+ assert!(path.contains("Z=URZ"));
+ assert!(path.contains("ipv4=yes"));
+ assert!(path.contains("ipv6=no"));
+ assert!(path.contains("hdlc_framing=no"));
+ assert!(path.contains("hostname="));
+ }
+
+ #[test]
+ fn failure_mapping() {
+ assert_eq!(
+ failure_event(&F5Error::AuthFailed("x".into())),
+ LifecycleEvent::Failed {
+ kind: FailureKind::Authentication,
+ detail: "authentication failed: x".into()
+ }
+ );
+ assert!(matches!(
+ failure_event(&F5Error::TunnelUpgradeRejected(403)),
+ LifecycleEvent::Failed {
+ kind: FailureKind::Network,
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn split_host_port_variants() {
+ assert_eq!(
+ split_host_port("vpn.example.com", 443),
+ ("vpn.example.com".into(), 443)
+ );
+ assert_eq!(
+ split_host_port("vpn.example.com:8443", 443),
+ ("vpn.example.com".into(), 8443)
+ );
+ assert_eq!(
+ split_host_port("https://vpn.example.com/path", 443),
+ ("vpn.example.com".into(), 443)
+ );
+ assert_eq!(
+ split_host_port("10.0.0.1:444", 443),
+ ("10.0.0.1".into(), 444)
+ );
+ }
+
+ #[test]
+ fn wrap_ip_selects_proto_by_version() {
+ let v4 = wrap_ip_in_ppp(&[0x45, 0, 0, 0]);
+ assert_eq!(&v4[..4], &[0xff, 0x03, 0x00, 0x21]);
+ let v6 = wrap_ip_in_ppp(&[0x60, 0, 0, 0]);
+ assert_eq!(&v6[..4], &[0xff, 0x03, 0x00, 0x57]);
+ }
+
+ #[test]
+ fn ppp_payload_extracts_ip() {
+ // FF 03 0021
+ let frame = [0xff, 0x03, 0x00, 0x21, 0xde, 0xad];
+ assert_eq!(ppp_payload_if_ip(&frame), Some(&[0xde, 0xad][..]));
+ // LCP control frame -> not IP
+ let lcp = [0xff, 0x03, 0xc0, 0x21, 0x01];
+ assert_eq!(ppp_payload_if_ip(&lcp), None);
+ }
+}
diff --git a/akon-core/src/vpn/f5/config.rs b/akon-core/src/vpn/f5/config.rs
new file mode 100644
index 0000000..7cf50ce
--- /dev/null
+++ b/akon-core/src/vpn/f5/config.rs
@@ -0,0 +1,571 @@
+//! F5 profile/options XML parsing (pure Rust, dependency-free).
+//!
+//! F5 BIG-IP returns flat XML for both the VPN profile and the tunnel options.
+//! The profile (`/vdesk/vpn/index.php3?outform=xml`) looks like:
+//!
+//! ```xml
+//!
+//!
+//! resourcename=/Common/demo
+//!
+//!
+//! ```
+//!
+//! The options XML has root `` whose
+//! many flat children carry the per-tunnel settings as element text, e.g.
+//! `SID`, `1`, `8.8.8.8`,
+//! `10.0.0.0/8`.
+//!
+//! Rather than pull in an XML crate, this module ships a tiny tolerant scanner
+//! sufficient for this flat structure: it walks `text` pairs
+//! (and bare self-closing tags), unescaping the five predefined XML entities.
+//!
+//! Protocol ground truth: openconnect `f5.c` (`parse_profile`, `parse_options`)
+//! and `auth-common.c` (`xmlnode_bool_or_int_value`).
+
+use crate::vpn::f5::F5Error;
+
+/// Parsed F5 VPN options (the data needed to bring up the tunnel).
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct F5Options {
+ /// `Session_ID` — the `/myvpn` `sess=` parameter.
+ pub session_id: Option,
+ /// `ur_Z` — the `/myvpn` `Z=` parameter.
+ pub ur_z: Option,
+ /// `IPV4_0` — whether IPv4 transport is enabled.
+ pub ipv4: bool,
+ /// `IPV6_0` — whether IPv6 transport is enabled.
+ pub ipv6: bool,
+ /// `hdlc_framing` — whether RFC1662 HDLC-like framing is used.
+ pub hdlc_framing: bool,
+ /// `idle_session_timeout` — idle timeout in seconds.
+ pub idle_timeout: Option,
+ /// `tunnel_dtls` — whether DTLS transport is offered.
+ pub dtls: bool,
+ /// `tunnel_port_dtls` — the UDP port for DTLS, when enabled.
+ pub dtls_port: Option,
+ /// `DNS0`..`DNS2` — DNS servers, in document order.
+ pub dns: Vec,
+ /// `DNSSuffix0`.. — DNS search domains, in document order.
+ pub domains: Vec,
+ /// `LAN0`.. — split-include routes (one tag may hold several whitespace-
+ /// separated routes).
+ pub routes: Vec,
+ /// `UseDefaultGateway0` — whether the default route should be installed.
+ pub default_gateway: bool,
+}
+
+/// A single flat XML element discovered by the [scanner](scan_elements):
+/// its tag `name` and decoded text `content` (empty for self-closing tags).
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct XmlElement {
+ name: String,
+ content: String,
+}
+
+/// Extract the resource params from the profile XML (first `` text
+/// inside a ``).
+///
+/// Returns [`F5Error::InvalidConfig`] if there is no ``
+/// containing a non-self-closing `` element.
+pub fn parse_profile(xml: &str) -> Result {
+ // Find a open tag whose type attribute is "VPN", then take
+ // the first .. text within that favorites block.
+ let mut rest = xml;
+ while let Some(open) = find_open_tag(rest, "favorites") {
+ let attrs = &rest[open.attrs_start..open.tag_end];
+ let block = &rest[open.tag_end..];
+ // Bound the search to this favorites block (up to its closing tag, if any).
+ let block = match find_close_tag(block, "favorites") {
+ Some(end) => &block[..end],
+ None => block,
+ };
+
+ if tag_attr(attrs, "type").as_deref() == Some("VPN") {
+ if let Some(params) = first_element_text(block, "params") {
+ return Ok(params);
+ }
+ }
+ rest = &rest[open.tag_end..];
+ }
+
+ Err(F5Error::InvalidConfig(
+ "no with a element".to_string(),
+ ))
+}
+
+/// Parse the options XML into [`F5Options`].
+///
+/// Requires at least one of `ipv4`/`ipv6` to be enabled **and** both `ur_z`
+/// and `session_id` to be present, mirroring openconnect's
+/// `(*ipv4 < 1 && *ipv6 < 1) || !*ur_z || !*session_id` failure check.
+/// Otherwise returns [`F5Error::InvalidConfig`].
+pub fn parse_options(xml: &str) -> Result {
+ let mut opts = F5Options::default();
+
+ for el in scan_elements(xml) {
+ let name = el.name.as_str();
+ let text = el.content.trim();
+
+ match name {
+ "Session_ID" => set_nonempty(&mut opts.session_id, text),
+ "ur_Z" => set_nonempty(&mut opts.ur_z, text),
+ "IPV4_0" => opts.ipv4 = bool_or_int_value(text).unwrap_or(false),
+ "IPV6_0" => opts.ipv6 = bool_or_int_value(text).unwrap_or(false),
+ "hdlc_framing" => opts.hdlc_framing = bool_or_int_value(text).unwrap_or(false),
+ "idle_session_timeout" => {
+ if let Ok(n) = text.parse::() {
+ opts.idle_timeout = Some(n);
+ }
+ }
+ "tunnel_dtls" => opts.dtls = bool_or_int_value(text).unwrap_or(false),
+ "tunnel_port_dtls" => {
+ if let Ok(p) = text.parse::() {
+ opts.dtls_port = Some(p);
+ }
+ }
+ "UseDefaultGateway0" => opts.default_gateway = bool_or_int_value(text).unwrap_or(false),
+ _ => {
+ // The flat, numbered families: DNS, DNSSuffix, LAN.
+ if let Some(rest) = name.strip_prefix("DNSSuffix") {
+ if is_all_digits(rest) && !text.is_empty() {
+ opts.domains.push(text.to_string());
+ }
+ } else if let Some(rest) = name.strip_prefix("DNS") {
+ if is_all_digits(rest) && !text.is_empty() {
+ opts.dns.push(text.to_string());
+ }
+ } else if let Some(rest) = name.strip_prefix("LAN") {
+ if is_all_digits(rest) {
+ // One LAN tag may carry several whitespace-separated routes.
+ for route in text.split_whitespace() {
+ opts.routes.push(route.to_string());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!opts.ipv4 && !opts.ipv6) || opts.ur_z.is_none() || opts.session_id.is_none() {
+ return Err(F5Error::InvalidConfig(
+ "options XML missing ur_Z, Session_ID, or any of IPV4_0/IPV6_0".to_string(),
+ ));
+ }
+
+ Ok(opts)
+}
+
+/// Store `text` into `slot` when non-empty (mirrors the openconnect behaviour
+/// where an empty element does not satisfy the `!*x` presence checks).
+fn set_nonempty(slot: &mut Option, text: &str) {
+ if !text.is_empty() {
+ *slot = Some(text.to_string());
+ }
+}
+
+/// True iff `s` is non-empty and consists solely of ASCII digits (used to gate
+/// the numbered tag families like `DNS0`, `LAN12`).
+fn is_all_digits(s: &str) -> bool {
+ !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
+}
+
+/// Interpret a flat F5 boolean/int value.
+///
+/// Mirrors openconnect's `xmlnode_bool_or_int_value`: a leading digit means the
+/// value is an integer (non-zero ⇒ `true`); otherwise `"yes"`/`"on"` ⇒ `true`
+/// and `"no"`/`"off"` ⇒ `false` (case-insensitive). Anything else ⇒ `None`.
+fn bool_or_int_value(text: &str) -> Option {
+ let t = text.trim();
+ let first = t.bytes().next()?;
+ if first.is_ascii_digit() {
+ // atoi-style: parse the leading integer run.
+ let digits: String = t
+ .bytes()
+ .take_while(u8::is_ascii_digit)
+ .map(char::from)
+ .collect();
+ return digits.parse::().ok().map(|n| n != 0);
+ }
+ if t.eq_ignore_ascii_case("yes") || t.eq_ignore_ascii_case("on") {
+ Some(true)
+ } else if t.eq_ignore_ascii_case("no") || t.eq_ignore_ascii_case("off") {
+ Some(false)
+ } else {
+ None
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Minimal flat-XML scanner
+// ---------------------------------------------------------------------------
+
+/// A located `` open tag.
+struct OpenTag {
+ /// Byte offset where the tag's attribute span begins (just after the name).
+ attrs_start: usize,
+ /// Byte offset just past the closing `>` of the open tag.
+ tag_end: usize,
+}
+
+/// Find the first `` open tag in `haystack` (ignoring self-closing
+/// `` forms). Returns its attribute span and end offset.
+fn find_open_tag(haystack: &str, name: &str) -> Option {
+ let bytes = haystack.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] != b'<' {
+ i += 1;
+ continue;
+ }
+ // Skip declarations/comments/processing-instructions/closing tags.
+ if matches!(bytes.get(i + 1), Some(b'/') | Some(b'!') | Some(b'?')) {
+ i += 1;
+ continue;
+ }
+ let after = i + 1;
+ if haystack[after..].starts_with(name) {
+ let next = after + name.len();
+ // The char after the name must delimit the tag name.
+ let delim = bytes.get(next).copied();
+ if matches!(
+ delim,
+ Some(b'>') | Some(b'/') | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r')
+ ) {
+ if let Some(close_rel) = haystack[next..].find('>') {
+ let tag_end = next + close_rel + 1;
+ // Ignore self-closing tags ("").
+ if bytes[tag_end - 2] != b'/' {
+ return Some(OpenTag {
+ attrs_start: next,
+ tag_end,
+ });
+ }
+ }
+ }
+ }
+ i += 1;
+ }
+ None
+}
+
+/// Find the byte offset (relative to `haystack`) of the start of the first
+/// `` closing tag.
+fn find_close_tag(haystack: &str, name: &str) -> Option {
+ let needle = format!("{name}");
+ let pos = haystack.find(&needle)?;
+ // Ensure the char after the name terminates it (avoid "").
+ let after = pos + needle.len();
+ let delim = haystack.as_bytes().get(after).copied();
+ if matches!(
+ delim,
+ Some(b'>') | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r')
+ ) {
+ Some(pos)
+ } else {
+ None
+ }
+}
+
+/// Return the decoded text of the first `..` element in `haystack`,
+/// or `None` if absent or self-closing.
+fn first_element_text(haystack: &str, name: &str) -> Option {
+ let open = find_open_tag(haystack, name)?;
+ let body = &haystack[open.tag_end..];
+ let end = find_close_tag(body, name)?;
+ Some(decode_entities(&body[..end]))
+}
+
+/// Walk every simple `text` (and self-closing ``) element
+/// in `xml`, returning each with its decoded text content in document order.
+///
+/// This is intentionally shallow: it does not build a tree. For the flat F5
+/// options document that is exactly what is needed — every leaf element under
+/// `