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
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
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.
Build the project first to compile
procket:rebar3 compileCopy 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/procketsudo chown root /usr/local/bin/procketsudo chmod 4750 /usr/local/bin/procketNote: In
config/sys.config, theprocketapp is configured to use/usr/local/bin/procketfor 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
- No Elixir.
- No umbrella project.
- No external framework dependencies.
- No CA/PKI logic or key exchange yet.