Skip to main content

README.md

# vpn

VPN Overlay Network for the Zencrypted ecosystem.

This repository currently contains a minimal Erlang/OTP VPN dataplane prototype.
It currently uses a temporary PSK encrypted dataplane. It intentionally does not
implement peer/session management, CA services, or key exchange yet.

## Architecture

The current local validation path is:

```text
TUN/TAP <-> Erlang <-> UDP <-> Erlang <-> TUN/TAP
```

Runtime layering:

```text
vpn_peer
    |
vpn_link
    |
vpn_udp
vpn_tun
```

`vpn_peer` is the stable public runtime API. `vpn_link` is a lower-level
transport component.

X.509 PKI integration is expected to use `synrc/ca` in a later milestone.
The current development trust store only verifies that configured peer
certificates are signed by the local development CA fixture.

## Modules

- `vpn_app` - OTP application entry point.
- `vpn_sup` - top-level supervisor.
- `vpn` - public API.
- `vpn_tun` - TUN/TAP integration layer.
- `vpn_udp` - UDP transport worker.
- `vpn_link` - bidirectional TUN/TAP to UDP link.
- `vpn_udp_sink` - local UDP test sink.
- `vpn_peer` - public runtime peer abstraction.
- `vpn_manager` - read-only management API for supervised peers.
- `vpn_trust_store` - development CA certificate trust store.

## Build

```sh
rebar3 compile
```

## Test

```sh
rebar3 eunit
```

## Demo Guide

This guide shows the current end-to-end VPN milestone: encrypted TUN peers,
X.509 identity, CA trust verification, JSON/HTML administration, and the
interactive N2O dashboard.

Architecture overview:

```text
peer_a (10.20.20.1)
      |
 encrypted UDP
      |
peer_b (10.20.20.2)
```

Packet path:

```text
TUN -> VPN -> UDP -> VPN -> TUN
```

### Start the demo

Build and start the application:

```sh
rebar3 compile
rebar3 shell
```

The configured peers are started by the OTP supervision tree from
`config/sys.config`.

### Verify the tunnel

From another terminal, ping `peer_b` through the local tunnel:

```sh
ping -4 -c 5 10.20.20.2
```

Expected result:

```text
0% packet loss
```

### Verify certificate identity

In the Erlang shell:

```erlang
Children = supervisor:which_children(vpn_peer_sup).
{_, Peer, _, _} = lists:keyfind({vpn_peer, peer_a}, 1, Children).
vpn_peer:identity_info(Peer).
```

Expected certificate metadata includes:

```text
issuer  = Zencrypted Dev CA
subject = peer_a
```

### Verify management APIs

```erlang
vpn_manager:running_peers().
vpn_manager:status().
vpn_manager:certificates().
```

### Verify JSON API

```sh
curl http://localhost:8080/api/admin/summary | jq .
```

Expected JSON includes:

```text
counts
peers
certificate information
```

### Verify Cowboy dashboard

Open:

```text
http://localhost:8080/admin
```

Expected:

```text
peer table visible
counts visible
```

### Verify N2O dashboard

Open:

```text
http://localhost:8080/admin/n2o
```

Expected:

```text
Reload Config button
peer table
Start / Stop actions
```

### Interactive demo

Stop `peer_a` from the N2O dashboard. Expected result:

```text
Running Peers: 1
Stopped Peers: 1
```

Start `peer_a` again. Expected result:

```text
Running Peers: 2
Stopped Peers: 0
```

Click `Reload Config`. Expected result:

```text
Configuration reloaded
```

### Current Milestone

```text
VPN dataplane operational
PKI identity operational
CA trust validation operational
Certificate/key ownership verification operational
JSON API operational
Cowboy dashboard operational
N2O dashboard operational
Interactive peer management operational
```

## VPN Management API

`vpn_manager` is the initial management layer for supervised peers. It is
intended to become the backend surface for the future N2O/EXO admin UI.

```erlang
vpn_manager:list_peers().
vpn_manager:running_peers().
vpn_manager:status().
vpn_manager:peer_status(peer_a).
vpn_manager:peer_info(peer_a).
vpn_manager:peer_stats(peer_a).

vpn_manager:stop_peer(peer_a).
vpn_manager:start_peer(peer_a).
vpn_manager:reload_config().
```

`list_peers/0` returns configured peers from application config.
`running_peers/0` returns currently active supervised peers.
`status/0` returns an aggregate snapshot for dashboard consumers:

```erlang
#{
    configured => [peer_a, peer_b],
    running => [peer_a, peer_b],
    peers => #{peer_a => #{running => true}}
}
```

`peer_info/1` returns identity and operational config:

```erlang
#{
    id => peer_a,
    identity => IdentityInfo,
    config => Config
}
```

Unknown peers return:

```erlang
{error, not_found}
```

Starting an already running peer returns:

```erlang
{error, already_started}
```

`reload_config/0` synchronizes runtime peers with the current application
configuration. It starts configured peers that are not running, stops running
peers that are no longer configured, and leaves already running configured peers
untouched:

```erlang
#{
    started => [peer_c],
    stopped => [peer_x],
    unchanged => [peer_a, peer_b],
    failed => []
}
```

The management API can start, stop, and reload configured peers. It does not
create, delete, persist, or hot-update peer configuration yet.

## Administration API

`vpn_admin` is the read-only facade intended as the future backend contract for
N2O/EXO dashboard pages:

```erlang
vpn_admin:dashboard().
vpn_admin:summary().
vpn_admin:summary_view().
vpn_admin:overview().
vpn_admin:peer_counts().
```

`dashboard/0` aggregates raw manager status and certificate inventory.
`summary/0` returns a compact first-screen view:

```erlang
#{
    counts => #{configured => 2, running => 2, stopped => 0, certificates => 2},
    peers => [
        #{
            id => peer_a,
            running => true,
            mode => tun,
            ip => "10.20.20.1",
            remote_peer_id => peer_b,
            crypto_failures => 0,
            frames_rejected => 0,
            certificate => #{trusted => true, key_match => true}
        }
    ]
}
```

## Admin View Model

`summary_view/0` converts the compact summary into JSON-safe values for future
N2O/Cowboy/REST/UI layers. It does not encode JSON and does not add a JSON
library dependency.

```erlang
vpn_admin:summary_view().
```

Example shape:

```erlang
#{
    counts => #{configured => 2, running => 2, stopped => 0, certificates => 2},
    peers => [
        #{
            id => <<"peer_a">>,
            running => true,
            mode => <<"tun">>,
            ip => <<"10.20.20.1">>,
            remote_peer_id => <<"peer_b">>,
            crypto_failures => 0,
            frames_rejected => 0,
            certificate => #{
                subject_cn => <<"peer_a">>,
                issuer_cn => <<"Zencrypted Dev CA">>,
                trusted => true,
                key_match => true,
                not_after => <<"270606195431Z">>
            }
        }
    ]
}
```

`overview/0` returns compact dashboard counts:

```erlang
#{
    configured_peers => 2,
    running_peers => 2,
    stopped_peers => 0,
    certificates => 2
}
```

Lifecycle operations remain in `vpn_manager`.

## Administration JSON Export

`summary_json/0` encodes the JSON-safe administration summary for future
Cowboy/N2O handlers. `summary_json_pretty/0` currently returns the same binary
and is reserved as a formatting extension point.

```erlang
vpn_admin:summary_json().
vpn_admin:summary_json_pretty().
```

Shell validation:

```erlang
Json = vpn_admin:summary_json().
is_binary(Json).

Decoded = jiffy:decode(Json, [return_maps]).
maps:get(<<"counts">>, Decoded).
maps:get(<<"peers">>, Decoded).
```

## HTTP Admin Endpoint

The application starts a local read-only Cowboy endpoint for the admin summary.
The port is configured with `{http_port, 8080}` under the `vpn` application
environment.

```bash
curl http://localhost:8080/api/admin/summary
```

Expected response:

```json
{
  "counts": {},
  "peers": []
}
```

The endpoint only supports `GET /api/admin/summary`. Peer lifecycle actions,
configuration writes, certificate issuance, TLS, authentication, and UI routes
are intentionally not exposed here.

## HTML Dashboard

The same Cowboy listener also serves a minimal read-only HTML dashboard:

```text
http://localhost:8080/
http://localhost:8080/admin
```

The page renders counts and a peer table from `vpn_admin:summary_view/0`.
It does not use JavaScript, templates, N2O, Nitro, WebSockets, or management
actions.

Runtime validation:

```bash
curl -i http://localhost:8080/
curl -i http://localhost:8080/admin
```

Expected:

```text
HTTP/1.1 200 OK
content-type: text/html
```

## Dashboard Actions

The dashboard includes simple HTML forms for local peer control:

```text
Start peer
Stop peer
Reload config
```

Routes:

```text
POST /admin/peer/peer_a/start
POST /admin/peer/peer_a/stop
POST /admin/reload
```

Each action redirects back to `/admin` with `HTTP 303 See Other`. The controls
delegate to the existing `vpn_manager` functions and do not add JavaScript,
N2O, WebSockets, authentication, authorization, or certificate management.

## N2O Dashboard

A read-only N2O/Nitro dashboard page is available separately from the plain
Cowboy dashboard:

```text
http://localhost:8080/admin/n2o
```

It renders `VPN Dashboard (N2O)`, peer counts, and the peer table from
`vpn_admin:summary_view/0`. It does not include start/stop/reload actions, live
updates, WebSockets, authentication, authorization, or certificate actions.

Both dashboard paths use the same UI model:

```text
vpn_manager
    ↓
vpn_admin
    ↓
summary_view
    ↓
Cowboy UI

vpn_manager
    ↓
vpn_admin
    ↓
summary_view
    ↓
N2O UI
```

Runtime validation:

```bash
curl -i http://localhost:8080/admin/n2o
```

Expected:

```text
HTTP/1.1 200 OK
content-type: text/html
```

## Interactive N2O Dashboard

The N2O dashboard includes interactive controls that update the counts and peer
table without a browser page reload:

```text
Start peer
Stop peer
Reload config
```

The controls use N2O events and Nitro DOM updates. Displayed state still comes
from `vpn_admin:summary_view/0`.

## Certificate Inventory

`vpn_manager` exposes certificate inventory helpers for administration screens:

```erlang
vpn_manager:certificates().
vpn_manager:certificate_info(peer_a).
```

An inventory entry includes runtime state and safe certificate metadata:

```erlang
#{
    peer_id => peer_a,
    running => true,
    trusted => true,
    key_match => true,
    subject => Subject,
    issuer => Issuer,
    serial_number => Serial,
    certificate_path => "priv/certs/peer_a.crt"
}
```

The inventory uses already-loaded peer identity data for running peers. It does
not re-read private keys or re-run trust validation for every request.

## Peer-Based Validation

Use `vpn_peer` for runtime validation. It owns the peer config and wraps the
lower-level `vpn_link`.

```erlang
PeerB = #{
    id => peer_b,
    remote_peer_id => peer_a,
    psk => <<"0123456789abcdef0123456789abcdef">>,
    mode => tun,
    ifname => <<"tun1">>,
    ip => "10.20.20.2",
    local_udp_port => 5556,
    remote_ip => {127,0,0,1},
    remote_udp_port => 5555,
    certificate_path => "priv/certs/peer_b.crt",
    private_key_path => "priv/certs/peer_b.key",
    ca_certificate_path => "priv/certs/ca.crt"
}.

PeerA = #{
    id => peer_a,
    remote_peer_id => peer_b,
    psk => <<"0123456789abcdef0123456789abcdef">>,
    mode => tun,
    ifname => <<"tun0">>,
    ip => "10.20.20.1",
    local_udp_port => 5555,
    remote_ip => {127,0,0,1},
    remote_udp_port => 5556,
    certificate_path => "priv/certs/peer_a.crt",
    private_key_path => "priv/certs/peer_a.key",
    ca_certificate_path => "priv/certs/ca.crt"
}.
```

Start both peers and reset counters:

```erlang
{ok, B} = vpn_peer:start_link(PeerB).
{ok, A} = vpn_peer:start_link(PeerA).

vpn_peer:reset_stats(A).
vpn_peer:reset_stats(B).
```

Run validation ping from another terminal:

```sh
ping -4 -c 10 10.20.20.2
```

Inspect peer statistics:

```erlang
vpn_peer:identity(A).
vpn_peer:config(A).
vpn_peer:stats(A).
vpn_peer:stats(B).
```

`identity/1` returns identity metadata, `config/1` returns operational
configuration without certificate paths, and `stats/1` returns runtime counters:

```erlang
#{
    id => PeerId,
    link => LinkStats
}
```

## Encrypted PSK Dataplane

Required peer config fields:

```text
id
remote_peer_id
psk
mode
ifname
ip
local_udp_port
remote_ip
remote_udp_port
certificate_path
private_key_path
ca_certificate_path
```

Packet pipeline:

```text
TUN -> vpn_frame -> vpn_crypto -> UDP
UDP -> vpn_crypto -> vpn_frame -> peer validation -> TUN
```

Successful validation:

```sh
rebar3 compile
rebar3 eunit
rebar3 shell
ping -4 -c 10 10.20.20.2
```

Expected ping result:

```text
10 packets transmitted
10 packets received
0% packet loss
```

Expected link stats:

```erlang
#{
    crypto_failures => 0,
    frames_rejected => 0,
    frames_accepted => N
}
```

where `N > 0`.

Negative PSK test: set different `psk` values for `peer_a` and `peer_b`.
Expected result: ping fails and `crypto_failures` increases.

The PSK is temporary and will later be replaced by CA/PKI-based key
establishment.

## Development Certificate Trust

Development fixtures live in `priv/certs`:

```text
ca.crt
ca.key
peer_a.crt
peer_a.key
peer_b.crt
peer_b.key
```

`peer_a.crt` and `peer_b.crt` are signed by the development CA. During peer
startup, `vpn_identity` loads the peer certificate/key PEM files, parses safe
certificate metadata, loads `ca_certificate_path` through `vpn_trust_store`, and
verifies that the peer certificate issuer matches the trusted CA and its
signature validates against that CA.

This is only local trust-store verification. It does not implement CRL, OCSP,
enrollment, certificate renewal, key exchange, or replacement of the temporary
PSK dataplane.

## Certificate Ownership Verification

A trusted certificate alone is insufficient. During peer startup,
`vpn_identity` also parses the configured private key and verifies that its
public part matches the public key in the configured certificate.

For example, configuring `peer_a.crt` with `peer_b.key` causes peer startup to
fail with a key mismatch. This check proves local certificate/key ownership for
the development fixtures; it does not implement certificate-based session keys
or a handshake yet.

## Config Driven Startup

Peers can be started from application configuration. Add `peers` under the `vpn`
application environment:

```erlang
{vpn, [
    {peers, [
        #{
            id => peer_a,
            name => <<"Peer A">>,
            remote_peer_id => peer_b,
            psk => <<"0123456789abcdef0123456789abcdef">>,
            mode => tun,
            ifname => <<"tun0">>,
            ip => "10.20.20.1",
            local_udp_port => 5555,
            remote_ip => {127,0,0,1},
            remote_udp_port => 5556,
            certificate_path => "priv/certs/peer_a.crt",
            private_key_path => "priv/certs/peer_a.key",
            ca_certificate_path => "priv/certs/ca.crt"
        },
        #{
            id => peer_b,
            remote_peer_id => peer_a,
            psk => <<"0123456789abcdef0123456789abcdef">>,
            mode => tun,
            ifname => <<"tun1">>,
            ip => "10.20.20.2",
            local_udp_port => 5556,
            remote_ip => {127,0,0,1},
            remote_udp_port => 5555,
            certificate_path => "priv/certs/peer_b.crt",
            private_key_path => "priv/certs/peer_b.key",
            ca_certificate_path => "priv/certs/ca.crt"
        }
    ]}
]}.
```

When the application starts, `vpn_peer_sup` reads:

```erlang
application:get_env(vpn, peers, []).
```

Then it starts and supervises one `vpn_peer` child per config entry. With no
configured peers, the application boots normally.

Start the shell and inspect configured children:

```sh
rebar3 shell
```

```erlang
supervisor:which_children(vpn_peer_sup).
```

## Local Tunnel Validation

The Erlang VM must have permission to create and configure TAP/TUN interfaces.

### Linux Setup

On Linux, give the active `beam.smp` binary `cap_net_admin` before starting the shell:

```sh
sudo setcap cap_net_admin=ep <beam.smp>
```

### macOS Setup

On macOS, setuid permissions must be configured for the `procket` helper binary.

1. Build the project first to compile `procket`:
   ```sh
   rebar3 compile
   ```

2. Copy the compiled helper binary to a system directory (like `/usr/local/bin`) and make it owned by root with setuid permissions enabled:
   ```sh
   sudo cp _build/default/lib/procket/priv/procket /usr/local/bin/procket
   sudo chown root /usr/local/bin/procket
   sudo chmod 4750 /usr/local/bin/procket
   ```

   *Note: In `config/sys.config`, the `procket` app is configured to use `/usr/local/bin/procket` for the helper executable via `{port_executable, "/usr/local/bin/procket"}`.*

Start the project shell:

```sh
rebar3 shell
```

Start both local tunnel endpoints:

```erlang
{ok, B} = vpn_link:start_link(
    <<"vpn1">>,
    "10.10.10.2",
    5556,
    {127,0,0,1},
    5555).

{ok, A} = vpn_link:start_link(
    <<"vpn0">>,
    "10.10.10.1",
    5555,
    {127,0,0,1},
    5556).
```

Reset counters before a focused run:

```erlang
vpn_link:reset_stats(A).
vpn_link:reset_stats(B).
```

Run IPv4 ping from another terminal:

```sh
ping -4 -c 10 10.10.10.2
```

Inspect counters:

```erlang
vpn_link:stats(A).
vpn_link:stats(B).
```

Expected ping result:

```text
10 packets transmitted
10 packets received
0% packet loss
```

Packet diagnostics classify frames as:

```text
arp
ipv4_icmp_echo_request
ipv4_icmp_echo_reply
ipv4_udp
ipv4_other
ipv6
unknown
```

## TUN Mode Validation

### 1. Start shell

```sh
rebar3 shell
```

### 2. Start endpoint B

```erlang
{ok, B} =
    vpn_link:start_link(
        <<"tun1">>,
        "10.20.20.2",
        tun,
        5556,
        {127,0,0,1},
        5555).
```

### 3. Start endpoint A

```erlang
{ok, A} =
    vpn_link:start_link(
        <<"tun0">>,
        "10.20.20.1",
        tun,
        5555,
        {127,0,0,1},
        5556).
```

### 4. Reset counters

```erlang
vpn_link:reset_stats(A).
vpn_link:reset_stats(B).
```

### 5. Run validation ping

```sh
ping -4 -c 10 10.20.20.2
```

Expected result:

```text
10 packets transmitted
10 packets received
0% packet loss
```

### 6. Inspect statistics

```erlang
vpn_link:stats(A).
vpn_link:stats(B).
```

Example healthy result:

```erlang
#{
  tun_rx_packets => N,
  udp_tx_packets => N,
  udp_rx_packets => N,
  tun_tx_packets => N
}
```

Packet counters should be approximately symmetric between both endpoints.

### 7. Packet diagnostics

Current packet classification:

```text
arp
ipv4_icmp_echo_request
ipv4_icmp_echo_reply
ipv4_udp
ipv4_other
ipv6
unknown
```

Diagnostics are intended for tunnel validation and troubleshooting.

## Notes

- No Elixir.
- No umbrella project.
- No external framework dependencies.
- No CA/PKI logic or key exchange yet.