Minimal dependency-free UDP/TCP tunnel for embedded Linux targets.
Ships as a userspace daemon and an optional standalone kernel module.
- Client/server architecture over UDP or TCP
- Auto-config mode — client fetches tunnel parameters from the server
- Protocol v2 — Noise handshake (
NKpsk0, X25519, ChaCha20-Poly1305, BLAKE2b), fresh keys per session and direction, always encrypted - No external dependencies — only libc required
- Cross-platform builds — x86_64, ARM, AArch64, MIPS (via CI)
- Custom kernel module —
camex.koreplacestun.kowith no external deps - Compact codebase — ~3,500 lines total, easy to audit
The server has a key pair; every client needs the server's public key and
a pre-shared key (the global one, or its own psk= from the server config).
(umask 077; camex --genkey > server.key) # server_key=… (secret) and public_key=…sudo ./camex --mode server --port 5800 --config /etc/camex/camex.conf \
--server-key-file server.key --psk secretsudo ./camex --mode client --auto --name 0203A104B5AE \
--server-host vpn.example.org --port 5800 \
--server-pubkey <public_key> --psk secretsudo ./camex --mode client \
--local-cidr 10.0.0.2/24 --gateway-ip 10.0.0.1 \
--server-host vpn.example.org --port 5800 \
--server-pubkey <public_key> --psk secretThe app connects in auto mode: the server sends the address, routes and MTU.
| Main screen | Settings |
|---|---|
![]() |
![]() |
- Server host / port — the camex server.
- Client name — the client ID, i.e. the
[client ID]block on the server. - Transport — UDP or TCP, as on the server.
- Server public key and Pre-shared key —
--server-pubkey --psk; the key must match the client'spsk=(or the global key). - Route all traffic through the tunnel — off: only the tunnel network and the server's routes go through the tunnel.
- Advanced — MTU, keepalive and server timeout (screenshot).
| Platform | Size (stripped) | UPX compressed |
|---|---|---|
| x86_64 | see CI | see CI |
| armhf | see CI | see CI |
| aarch64 | see CI | see CI |
| mipsel | see CI | see CI |
Sizes are updated by CI on each release. See the latest build artifacts or releases.
| Requirement | Details |
|---|---|
| Linux kernel | 3.10 or later |
| TUN support | CONFIG_TUN=y (/dev/net/tun) or camex.ko loaded (/dev/camex) |
| Privileges | root or CAP_NET_ADMIN + CAP_NET_RAW (non-root with camex.ko: also access to /dev/camex, see Kernel Module) |
| C compiler | C99-compatible (GCC or Clang) |
make# Prerequisites: kernel headers
make kmodmake && make kmodmake CROSS_COMPILE=mipsel-linux-sudo make kmod-installmake clean # remove userspace build artifacts
make kmod-clean # remove kernel module build artifacts
make distclean # clean everythingThe app auto-detects the available TUN backend at startup. It tries /dev/net/tun
(tun.ko) first; if that fails or TUNSETIFF is unavailable, it falls back to
/dev/camex (camex.ko). Both backends work identically from the protocol perspective.
Override auto-detection with --tun-dev <path>:
sudo ./camex --mode client ... --tun-dev /dev/camex| Feature | tun.ko (/dev/net/tun) |
camex.ko (/dev/camex) |
|---|---|---|
| Interface name | dynamic (tun0, tun1, …) |
always camex |
| Requires TUNSETIFF ioctl | Yes | No |
| External dependency | CONFIG_TUN=y or tun.ko loaded |
camex.ko loaded |
| IPv4 | Yes | Yes |
| IPv6 | Yes | No (IPv4 only) |
| Multiple instances | Yes | No (one) |
| Bare IPv4 packets | Yes (with IFF_NO_PI) |
Yes |
| Mode | Description |
|---|---|
client |
Creates a TUN interface and connects to the server |
server |
Listens for many clients and forwards tunnel packets |
sudo ./camex \
--mode client \
--auto \
--name 0203A104B5AE \
--server-host vpn.example.org \
--port 5800 \
--server-pubkey <public_key> --psk secretManual client mode:
sudo ./camex \
--mode client \
--local-cidr 10.0.0.2/24 \
--gateway-ip 10.0.0.1 \
--server-host vpn.example.org \
--port 5800 \
--route-cidr 192.168.100.0/24 \
--server-pubkey <public_key> --psk secretWith explicit TUN backend:
sudo ./camex \
--mode client \
--local-cidr 10.0.0.2/24 \
--gateway-ip 10.0.0.1 \
--server-host vpn.example.org \
--port 5800 \
--server-pubkey <public_key> --psk secret \
--tun-dev /dev/camexsudo ./camex \
--mode server \
--port 5800 \
--bind-ip 0.0.0.0 \
--config /etc/camex/camex.conf \
--server-key-file /etc/camex/server.key \
--psk secret| Option | Short | Description |
|---|---|---|
--mode <mode> |
-M |
Operation mode: client or server (required) |
--auto |
-a |
Fetch tunnel parameters from the server |
--name <id> |
-n |
Client ID (auto mode; optional in manual mode) |
--local-cidr <cidr> |
-l |
Tunnel address in address/prefix form |
--gateway-ip <addr> |
-g |
Client gateway inside the tunnel |
--server-host <addr> |
-s |
Server hostname or IP for client mode |
--port <port> |
-p |
Server port for client or listen port for server |
--bind-ip <addr> |
-b |
Server bind address (default: 0.0.0.0) |
--config <path> |
-f |
Server config file path |
--route-cidr <cidr> |
-c |
Extra route to install on the client; repeatable |
--mtu <size> |
-t |
Tunnel MTU, 576–9000 (default: 1500) |
--psk <key> |
-k |
Pre-shared key: hex:<64 hex digits>, base64:<44 chars>, or a passphrase (stretched with Argon2id). Required on clients |
--server-pubkey <key> |
Client: the server's public key (from --genkey) |
|
--server-key-file <path> |
Server: file with its private key (or server_key= in [server]) |
|
--genkey |
Print a new server key pair (server_key=, public_key=) and exit |
|
--encrypt |
-e |
Accepted for compatibility; protocol v2 is always encrypted |
--tun-dev <path> |
-T |
TUN device path (default: auto-detect /dev/net/tun then /dev/camex) |
--pid-file <path> |
-P |
Write PID to file on startup |
--bind-dev <iface> |
-d |
Bind socket to a specific network interface (SO_BINDTODEVICE) |
--transport <proto> |
-R |
Transport protocol: udp (default) or tcp |
--version |
-v |
Print version and exit |
--help |
-h |
Print usage and exit |
Ready-to-use configuration files are available in the examples/ directory:
| File | Description |
|---|---|
examples/camex.conf |
Server-side configuration file format reference |
examples/camex.service |
systemd service unit for camex daemon |
examples/README.md |
Usage instructions for all examples |
The server reads a flat key=value config file with one client record per block.
See examples/camex.conf for the full format.
sudo ./camex --mode server --port 5800 --config /etc/camex/camex.confAuto-mode clients get their addresses from the config file. A client whose ID
has no [client ID] block gets [defaults], or, when no address is configured,
one from the auto pool 10.200.0.2–10.200.0.254 (addresses in use and those
of [client ID] blocks are skipped; addresses of expired clients are reused).
To serve only the IDs listed in the file, set allow_unknown_clients=no in the
[server] block.
After a change to the file, kill -HUP the server. A running auto-mode client
picks up a changed address, gateway, MTU or routes with its next handshake (a
key renewal, at most 10 minutes later, or a reconnect): it reopens its tunnel
with the new settings and replaces its routes, without a restart.
camex is a small tunnel for cameras, not a general-purpose VPN. Know what it protects before exposing it to an untrusted network.
- Handshake.
Noise_NKpsk0_25519_ChaChaPoly_BLAKE2b: the client knows the server's public key, both sides know the client's pre-shared key. Every session gets fresh keys, separate for each direction (forward secrecy: a leaked key does not decrypt recorded sessions). - Packets. ChaCha20-Poly1305: the payload is encrypted, the 17-byte header (type, session index, counter) is authenticated but readable. Packets that fail authentication are dropped. Keys are renewed every 10 minutes.
- Keys. The pre-shared key is a raw key (
hex:,base64:) or a passphrase stretched with Argon2id. A captured handshake does not let anyone test passphrase guesses offline: that needs the server's private key. - Per-client keys. A
psk=in a[client ID]block gives that client its own key; it can register only with it, and the global key no longer works for that ID (unless both passphrases are the same, which makes them one key). See Notes for the exact rules. - Source addresses. A client may send only from its own tunnel address; packets with another source address are dropped.
- Replay protection. Data packets have a 2048-packet window per session. While a client has a live session, a handshake with an older timestamp is rejected; a replayed handshake can never take a working session over, since a new session replaces the old one only with the client's first packet on it (a client whose clock went back after a reboot still reconnects). Packets of an ended session cannot be replayed into a new one: its keys are different.
- Roaming. A session is found by its index, not by the client's address: after a NAT rebinding the client keeps its session.
- Config reload. After
SIGHUP, a live session whose key was removed or changed, or now belongs to another client, ends.
- The server's private key lets its holder impersonate the server to
clients that know the pre-shared key: keep
server_keysecret. - Metadata. Packet sizes, timing, endpoints and the cleartext header (packet type, session index, counter) are visible.
- There is no unencrypted mode; for an open tunnel without encryption see OpenIPC VTesse.
- Inside the tunnel traffic is not filtered: any client can reach the server
and other clients on the tunnel network. Restrict that with a firewall on the
server's tunnel interface (
tun0or similar with/dev/net/tun,camexwith the kernel module; the server logs its name at startup).
- TCP connections that have not completed a handshake are limited (4 per source address, 32 in total) and closed after 10 s. A handshake carries a MAC keyed with the server's public key: packets from anyone who does not know it are dropped after one hash. The server processes at most 200 handshakes per second.
- Log messages that a peer can trigger (dropped packets, rejected registrations) are rate-limited; the number suppressed is logged.
- The auto pool hands out
10.200.0.2–.254and never gives one address to two live clients (an expired client's address is handed out again; a client that reconnects, also after a server restart, gets its previous address back while it is free);allow_unknown_clients=noserves only auto-mode IDs listed in the config file (manual clients are bound by their key and address, see Notes).
- Keep the server key file readable by the server only (
umask 077); give the clientspublic_keyonly. - Prefer raw keys (
hex:/base64:) or long random passphrases. - Give each camera its own
psk=in a[client ID]block, and setallow_unknown_clients=nowhen all auto-mode cameras are listed. - Run the server with only the capabilities it needs (
CAP_NET_ADMIN; with the kernel module also file access to/dev/camex, see below), and firewall the tunnel interface.
/dev/camex is created 0600, and open() requires CAP_NET_ADMIN; see
Key Design Decisions. The module does no encryption:
encryption is done by the userspace app.
The app uses VpnService, so Android shows the VPN key icon while the tunnel is
up; there is no way around that with VpnService (a client without TUN is
tracked in #21). Only the tunnel network and the routes the server sends go
through the tunnel; a 0.0.0.0/0 route from the server is ignored unless
Route all traffic through the tunnel is enabled in the settings. That
switch covers IPv4 only: IPv6 traffic does not go through the tunnel.
Userspace Kernel
────────────────── ──────────────────────────────────────────
┌─────────────────────────────────┐
write(fd, ipv4_pkt) ──────────► │ copy_from_user() │
│ netif_rx(skb) │
│ │ │
│ ▼ │
│ IP stack / routing │
│ │ │
│ ▼ │
│ camex_net_xmit() │
│ enqueue → rx_queue │
│ wake_up(rx_wait) │
read(fd, buf) ◄────────── │ copy_to_user() │
└─────────────────────────────────┘
poll/select/epoll ◄────────── wait_queue (rx_wait)
/dev/camex ←────────────────────────────────────── net interface "camex"
Two independent kernel objects — a character device (miscdevice) and
a network interface (net_device) — share a single camex_priv
instance. Communication between them flows through the rx_queue
protected by a spinlock.
The upstream /dev/net/tun is a separate module (drivers/net/tun.c)
that must be either loaded or compiled into the kernel. camex registers
its own net_device and miscdevice directly, using only core kernel
subsystems (netdev, misc, skbuff) that are always present and
cannot be unloaded or disabled.
Using misc_register() automatically:
- allocates a minor number dynamically (no conflicts);
- creates
/dev/camexvia udev/devtmpfs without any udev rules; - sets permissions to
0600(root only).
open() additionally requires CAP_NET_ADMIN: the device injects
packets into the network stack and exposes routed traffic, which is a
network-admin operation, just like configuring a tun device.
A daemon that runs as a non-root user with CAP_NET_ADMIN + CAP_NET_RAW
also needs file access to the node, which 0600 does not give it. Grant it
to a dedicated group with a udev rule (the capability check still applies):
# /etc/udev/rules.d/90-camex.rules
KERNEL=="camex", GROUP="camex", MODE="0660"or, without udev, after each insmod: chgrp camex /dev/camex && chmod 0660 /dev/camex.
camex_net_xmit() is called from softirq context (not from process
context), so spin_lock_irqsave is used instead of a mutex to protect
the queue. This prevents deadlocks when called from interrupt context.
Instead of busy-waiting or timers, the standard kernel mechanism is
used: the process sleeps on a wait_queue, and camex_net_xmit()
wakes it up via wake_up_interruptible(). Signals correctly interrupt
the wait (-ERESTARTSYS).
On write(), the first byte of the buffer is checked before
copy_from_user via get_user(). If the IP version is not 4,
-EINVAL is returned without allocating an skb, preventing garbage
or non-IPv4 packets from entering the stack.
g_priv->attached is an atomic_t taken with atomic_cmpxchg(). If
/dev/camex is already open, a subsequent open() immediately returns
-EBUSY, and two concurrent open() calls can never both succeed. On
close() the queue is flushed and the slot released under the same lock
that camex_net_xmit() takes, so no packet is left for the next open().
The interface is declared as having no link-layer address. The kernel
does not attempt to resolve MAC addresses (ARP) and does not prepend
Ethernet headers. Userspace sends and receives bare IPv4 datagrams
starting at the version byte 0x45....
All #if LINUX_VERSION_CODE guards are grouped into a single #define
block at the top of the file. The driver body uses only macro names
(CAMEX_ALLOC_NETDEV, CAMEX_POLL_T, CAMEX_SKB_PUT, etc.) with no
conditional compilation inside functions. This simplifies auditing and
maintenance.
| Range | Status | Adapted API |
|---|---|---|
| 3.10 – 3.16 | ✅ | alloc_netdev(sz, name, setup) — 3 arguments |
| 3.17 – 3.19 | ✅ | alloc_netdev + NET_NAME_PREDICTABLE — 4 arguments |
| 4.0 – 4.9 | ✅ | min_mtu/max_mtu fields absent from net_device |
| 4.10 – 4.15 | ✅ | min_mtu/max_mtu fields introduced |
| 4.16 – 4.19 | ✅ | poll returns __poll_t; EPOLL* constants |
| 4.20+ | ✅ | skb_put() returns void* instead of unsigned char* |
| 5.x | ✅ | no changes required |
| 6.0 – 6.6 LTS | ✅ | no changes required |
Note: Kernels 3.0 – 3.9 are theoretically covered by the same shims but are extremely rare in practice (RHEL 6, early embedded).
# Debian / Ubuntu
sudo apt install linux-headers-$(uname -r) build-essential
# RHEL / CentOS 7
sudo yum install kernel-devel-$(uname -r) gcc make
# Fedora / RHEL 8+
sudo dnf install kernel-devel gcc make
# Alpine Linux
sudo apk add linux-headers build-basemake kmodsudo insmod camex.koVerify:
dmesg | tail -5
# camex: loaded — /dev/camex <-> net:camex (kernel 5.15)
ls -la /dev/camex
# crw------- 1 root root 10, 58 ... /dev/camex
ip link show camex
# camex: <POINTOPOINT,NOARP> mtu 1500 ...# Install the module into the kernel tree
sudo make kmod-install # copies camex.ko and runs depmod -a
# Enable autoload
echo "camex" | sudo tee /etc/modules-load.d/camex.confsudo rmmod camexUnloading is blocked while
/dev/camexis held open by any process (rmmodreturnsEBUSY).
| Operation | Behaviour |
|---|---|
open() |
Needs CAP_NET_ADMIN; one fd at a time, EBUSY otherwise |
read() |
Returns one IPv4 packet. Blocks when queue is empty |
read() + O_NONBLOCK |
Returns EAGAIN when queue is empty |
write() |
Injects one IPv4 packet into the stack. Minimum 20 bytes |
write() non-IPv4 |
Returns EINVAL |
poll() |
POLLIN when a packet is available; POLLOUT always |
close() |
Flushes the queue, clears carrier, releases the slot |
| Code | Cause |
|---|---|
EPERM |
open() without CAP_NET_ADMIN |
EBUSY |
/dev/camex is already open by another process |
ENODEV |
Module is being unloaded |
EMSGSIZE |
read() buffer smaller than packet length (packet dropped) |
EINVAL |
write(): size < 20 or > 65535 bytes, or not IPv4 |
EAGAIN |
read() with O_NONBLOCK, queue is empty |
ENOMEM |
Failed to allocate an skb |
-
IPv4 only. IPv6 is intentionally excluded. A
write()with an IPv6 packet returns-EINVAL. -
Single process at a time. Only one file descriptor may hold
/dev/camexopen. A secondopen()returnsEBUSY. For multi-process schemes, multiplex via a Unix socket in userspace. -
Single device instance. Exactly one
camexinterface and one/dev/camexdevice are created. Supporting multiple instances requires extending the code with ag_priv[]array and name suffixes. -
TUN (Layer 3) only. TAP (Layer 2, Ethernet frames) is not implemented. The interface has no MAC address (
ARPHRD_NONE). ARP does not work. -
No ioctl compatibility with
/dev/net/tun. The commandsTUNSETIFF,TUNGETIFF,TUNSETPERSIST, and other ioctls from the upstream driver are not implemented. Programs that useioctl(fd, TUNSETIFF, ...)will not work with camex without modification. -
MTU is fixed at 1500 bytes. Changing it via
ip link set camex mtu Nis not blocked by the kernel, but camex does not enforce the MTU onwrite().
-
Queue depth is 64 packets. When full, new packets are silently dropped and
tx_droppedis incremented (visible inip -s link). Adjust theQUEUE_LIMITconstant in the source. -
Each queued packet is stored as an
sk_buff. Everysk_buffconsumes ~200 bytes of header plus packet data. At 64 × 1500 bytes the maximum queue memory footprint is approximately 96–110 KB. -
A
read()with a too-small buffer destroys the packet. If the buffer is smaller than the packet length, the packet is lost andEMSGSIZEis returned. Always read with a buffer of at least 1500 bytes (or the interface MTU).
-
Concurrent
read()calls from multiple threads of the same process are not serialised at the fd level — protect access with a mutex in userspace. -
Concurrent
write()calls from multiple threads are safe: each call independently allocates its ownskb.
-
No masquerading/NAT out of the box. To route traffic from
camexto the internet, configureiptables -t nat -A POSTROUTINGmanually. -
netif_rx()vsnetif_receive_skb(). The driver usesnetif_rx()(the older softirq-deferred path). On high-throughput systems,netif_receive_skb()offers lower latency but requires process or softirq context — the substitution is trivial if needed.
| Feature | camex | tun.ko |
|---|---|---|
| External module dependency | No | Requires tun.ko |
| IPv4 | Yes | Yes |
| IPv6 | No (intentional) | Yes |
| TAP (L2) | No | Yes |
| Multiple device instances | No (one) | Yes (dynamic) |
| Multiple open file descriptors | No (one) | Yes |
| ioctl TUNSETIFF / TUNGETIFF | No | Yes |
| Persistent device | No | Yes |
| poll / select / epoll | Yes | Yes |
| O_NONBLOCK | Yes | Yes |
Statistics (ip -s link) |
Yes | Yes |
| Kernel 3.x support | Yes | Yes |
| Code size | ~490 lines | ~3500 lines |
All #if LINUX_VERSION_CODE guards are placed in a dedicated #define
block at the top of camex-k.c. The driver body uses only macro names
(CAMEX_ALLOC_NETDEV, CAMEX_POLL_T, CAMEX_SKB_PUT, etc.) with no
version conditionals inside functions. This keeps the logic readable and
easy to audit.
On insmod:
alloc_netdev+camex_privinitialisationregister_netdev→ interface appears inip linkmisc_register→/dev/camexappears in the filesystem
On rmmod:
misc_deregister→/dev/camexdisappears (newopen()calls blocked)g_priv = NULL→ protects against use-after-free in xmit pathunregister_netdev+free_netdev
The reverse order guarantees that by the time memory is freed, no new accesses can arrive through either path (fd or netdev).
netif_carrier_offon init — the interface showsNO-CARRIERuntil userspace opens/dev/camex.netif_carrier_ononopen()— carrier becomes active.netif_carrier_offonclose()— carrier is cleared; routes through the interface become unreachable.
camex_net_xmit() is called from softirq context where sleeping is
forbidden. Therefore kmalloc for pkt_node uses GFP_ATOMIC. If
memory is unavailable in this context the packet is dropped
(tx_dropped++).
The only place to change the software version is src/version.h:
#define CAMEX_VERSION "X.Y.Z"All other version references (Android, CI, git tags) must match this value.
The CI pipeline enforces this automatically: a release build will fail if the
git tag does not match CAMEX_VERSION.
- Bump
src/version.h— updateCAMEX_VERSIONto the new version. - Sync Android versions — run
scripts/sync-android-version.sh(ormake apkwhich calls it automatically). This updates:android/app/build.gradle.kts—versionName+versionCodeandroid/app/src/main/AndroidManifest.xml—android:versionName+android:versionCode
- Commit:
git commit -m "chore: bump version to X.Y.Z" - Tag:
git tag -a vX.Y.Z -m "Release vX.Y.Z" - Push:
git push origin master && git push origin vX.Y.Z - CI creates the GitHub Release automatically with artifacts named
camex-X.Y.Z-<platform>.
versionName(Android) =CAMEX_VERSION(e.g.,"2.3.6")versionCode(Android) =MAJOR * 10000 + MINOR * 100 + PATCH(e.g.,20306)- Git tag =
v+CAMEX_VERSION(e.g.,v2.3.6) - GitHub Release title =
camex vX.Y.Z
If a release needs to be triggered manually (e.g., re-uploading artifacts),
use the workflow_dispatch event in the Actions tab with the version input
set to the tag name (e.g., v2.3.6).
- Each client should use a unique tunnel IP.
- The server relays packets based on the inner IPv4 destination address.
- Client sockets are outbound only; no listening port is opened on the client side.
- In auto mode,
-l,-g, and-care supplied by the server. examples/camex.confshows the server-side parameter database format.- Protocol v2 works over both UDP and TCP transport; every session starts with
a handshake (
--server-key-fileon the server,--server-pubkeyand--pskon the client). camex 3.x does not talk to camex 2.x. - The server supports per-client PSKs: add
psk=<key>inside a[client ID]block in the config file. Keys are bound to client IDs:- a client that has its own key can register only with that key;
- the global
--pskkey works only for clients without their own key; - a manual client with its own key may omit
--name: the key identifies it; - a manual client may only claim the
local_cidraddress of its own[client ID]block, and never the address of another client that has its own key.
- The server accepts data packets only from an established session and only with the client's own tunnel address as the source; other source addresses are dropped.
MIT. See LICENSE for the full terms.
- Questions and bug reports: GitHub Issues
- Documentation: openipc.org

