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:

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

Runtime layering:

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

Build

rebar3 compile

Test

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:

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

Packet path:

TUN -> VPN -> UDP -> VPN -> TUN

Start the demo

Build and start the application:

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:

ping -4 -c 5 10.20.20.2

Expected result:

0% packet loss

Verify certificate identity

In the Erlang shell:

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:

issuer = Zencrypted Dev CA
subject = peer_a

Verify management APIs

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

Verify JSON API

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

Expected JSON includes:

counts
peers
certificate information

Verify Cowboy dashboard

Open:

http://localhost:8080/admin

Expected:

peer table visible
counts visible

Verify N2O dashboard

Open:

http://localhost:8080/admin/n2o

Expected:

Reload Config button
peer table
Start / Stop actions

Interactive demo

Stop peer_a from the N2O dashboard. Expected result:

Running Peers: 1
Stopped Peers: 1

Start peer_a again. Expected result:

Running Peers: 2
Stopped Peers: 0

Click Reload Config. Expected result:

Configuration reloaded

Current Milestone

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.

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:

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

peer_info/1 returns identity and operational config:

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

Unknown peers return:

{error, not_found}

Starting an already running peer returns:

{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:

#{
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:

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:

#{
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.

vpn_admin:summary_view().

Example shape:

#{
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:

#{
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.

vpn_admin:summary_json().
vpn_admin:summary_json_pretty().

Shell validation:

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.

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

Expected response:

{
"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:

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:

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

Expected:

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

Dashboard Actions

The dashboard includes simple HTML forms for local peer control:

Start peer
Stop peer
Reload config

Routes:

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:

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:

vpn_manager
vpn_admin
summary_view
Cowboy UI
vpn_manager
vpn_admin
summary_view
N2O UI

Runtime validation:

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

Expected:

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:

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:

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

An inventory entry includes runtime state and safe certificate metadata:

#{
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.

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:

{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:

ping -4 -c 10 10.20.20.2

Inspect peer statistics:

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:

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

Encrypted PSK Dataplane

Required peer config fields:

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:

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

Successful validation:

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

Expected ping result:

10 packets transmitted
10 packets received
0% packet loss

Expected link stats:

#{
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:

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:

{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:

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:

rebar3 shell
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:

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:

    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:

    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:

rebar3 shell

Start both local tunnel endpoints:

{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:

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

Run IPv4 ping from another terminal:

ping -4 -c 10 10.10.10.2

Inspect counters:

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

Expected ping result:

10 packets transmitted
10 packets received
0% packet loss

Packet diagnostics classify frames as:

arp
ipv4_icmp_echo_request
ipv4_icmp_echo_reply
ipv4_udp
ipv4_other
ipv6
unknown

TUN Mode Validation

1. Start shell

rebar3 shell

2. Start endpoint B

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

3. Start endpoint A

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

4. Reset counters

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

5. Run validation ping

ping -4 -c 10 10.20.20.2

Expected result:

10 packets transmitted
10 packets received
0% packet loss

6. Inspect statistics

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

Example healthy result:

#{
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:

arp
ipv4_icmp_echo_request
ipv4_icmp_echo_reply
ipv4_udp
ipv4_other
ipv6
unknown

Diagnostics are intended for tunnel validation and troubleshooting.

Notes