- Building
- vtesse-cli
- Inbound forwards
- Installation
- U-Boot environment (helpdesk access)
- Configuration file
- Device ID
- Addressing (DHCP)
- Local proxy
- UDP relay (RTP from cameras)
- Errors
- Probe tool
C99 + POSIX, plain make.
make # build/libnetcore.a, build/libvtesse.a, build/vtesse-cli
make test # unit tests and client scenarios against a scripted server
make LWIP_PROFILE=medium # lwIP memory profile: large (default), medium, small
make SOCKS=0 UDP_RELAY=0 # leave out optional modules (default: all in)
make CROSS_COMPILE=mipsel-linux-musl- STATIC=1 # cross compile, static binaryStatic musl binaries for cameras and routers are built by
ci/build-musl.sh (also used by CI):
QEMU=1 ci/build-musl.sh arm32 dist # downloads the toolchain, runs the tests under qemu| Target | Toolchain | Devices |
|---|---|---|
arm32 |
OpenIPC hisilicon-hi3516cv100 (arm musleabi) |
32-bit ARM cameras |
mipsel |
OpenWrt 23.05 ramips/mt7621 (musl, soft-float) |
Ingenic cameras, little-endian MIPS routers (MediaTek MT7620/7621/7628) |
arm64 |
Bootlin aarch64 musl | 64-bit ARM |
mips |
OpenWrt 23.05 ath79/generic (musl, soft-float) |
big-endian MIPS routers (Atheros ath79 and others) |
MIPS builds are soft-float: router SoCs have no FPU and their kernels often lack FPU
emulation, so hard-float binaries end with Illegal instruction there (found on an
OpenWrt MT7620 router, #30).
| x86_64 | Bootlin x86-64 musl | PCs, servers |
Optional modules (#29), vtesse-cli --version lists what a binary contains:
| Switch | Leaves out | arm32/x86_64 code saved |
|---|---|---|
SOCKS=0 |
SOCKS5 server (port forwards stay) | ~1.7 KB |
UDP_RELAY=0 |
UDP relay | ~2 KB |
A disabled feature is refused with a clear message (--socks: built without SOCKS5).
Release binaries contain everything, one per platform. The TAP backend (--tap, a kernel
interface) and the LWIP=0 and TAP=0 switches were removed in 2.0.0 (#72).
Each target gives vtesse-<target>; x86_64 also vtund-3.0.2-x86_64, the VTun server with our patches
(server/README.md). Use musl builds on devices: a static glibc binary cannot resolve host names
without the NSS libraries of the same glibc. Prebuilt binaries are on the
releases page (latest is rebuilt
from every push to main).
vtesse-cli [--config FILE] [--seconds N] [-v]
[--socks PORT] [--forward LPORT:IP:RPORT]... [--listen ADDR]
[--accept PORT[:IP:PORT]]...
[--fetch http://IP[:PORT]/PATH] [--udp-relay PORT[:LOCAL_PORT]]...
[--ip A.B.C.D/P|learn [--gw A.B.C.D]]
vtesse --install [--host SERVER] [--port N] [--password PASSWORD|-]
[--session ID | --id-from IFACE] [--ip A.B.C.D/P] [--gw A.B.C.D]
[--no-start] [--system SYSTEM] [--root DIR]
vtesse --uninstall [--system SYSTEM] [--root DIR]
| Option | Meaning |
|---|---|
--config FILE |
configuration file, default ~/.config/vtesse/probe.conf |
--seconds N |
stop after N seconds (default: run until SIGINT/SIGTERM) |
--id-from IFACE |
device ID = MAC of the local interface IFACE (overrides session/id_from of the file) |
--socks PORT |
SOCKS5 server on LISTEN:PORT |
--forward LPORT:IP:RPORT |
TCP forward LISTEN:LPORT → IP:RPORT through the tunnel (repeatable, up to 16) |
--listen ADDR |
address of the local listeners, default 127.0.0.1 |
--accept PORT[:IP:PORT] |
inbound forward: connections from the tunnel to PORT of the tunnel address go to IP:PORT on this machine (default: the same port on the address of the default-route interface); repeatable, up to 4 |
--udp-relay PORT[:LOCAL_PORT] |
UDP relay: datagrams to the tunnel address on PORT go to 127.0.0.1:LOCAL_PORT (default the same port); repeatable, up to 3 |
--fetch URL |
one HTTP GET through the stack once an address is bound (diagnostics) |
--ip A.B.C.D/P, --gw A.B.C.D |
manual IPv4 instead of DHCP; override ip/gw of the file (--debug-ip/--debug-gw are accepted as old names) |
--ip learn |
type tun: the address is learnt from the first packet the server sends (manual IPv4) |
--show-config |
print the settings in effect (file, U-Boot environment, defaults) and exit |
--no-env |
ignore the U-Boot environment |
--install, --uninstall |
install the binary as a service, or remove it |
The CLI brings the tunnel and the userspace stack up, prints link events, the DHCP
address and routes, runs the local proxy and the inbound forwards and prints proxy
statistics on exit. There is no kernel interface: the TAP backend (--tap, --ifname,
--hook) was removed in 2.0.0 (#72), and those options are refused with that reason. Exit
code 0 means the tunnel was up at the end (and --fetch, if given, succeeded).
Before it starts, the client waits for a default route (any interface): without one
there is nothing to connect to. The wait has no time limit and cannot be turned off;
it prints waiting for a default route, then a line every minute, and
default route present after N s when one appears. SIGINT/SIGTERM end the wait
(exit code 0). --show-config and --install do not wait. At boot this covers an
uplink that gets its route after the service starts (Wi-Fi with a background DHCP
client).
vtesse-cli --socks 1080 --forward 8554:10.78.0.10:554
curl --socks5 127.0.0.1:1080 http://10.78.0.10/
ffplay -rtsp_transport tcp rtsp://127.0.0.1:8554/stream[ 0.504] up: VTUN server ver 3.X 10/13/2026, flags <TeK>
[ 3.512] address 10.77.0.169 mask 255.255.255.0, option 3 10.77.0.1 (ignored: classless routes present)
[ 3.512] route 10.78.0.0/24 via 10.77.0.1
[ 3.512] route 10.79.0.0/24 via 10.77.0.1
[ 3.522] fetch 10.78.0.10:80/: ok, 205 bytes, "HTTP/1.0 200 OK"
Without a kernel interface the device's own services (web interface, ssh, RTSP) are
not on the tunnel by themselves. accept=PORT[:IP:PORT] (or --accept, #67) makes the
stack listen on PORT of the tunnel address and relays every connection from the
tunnel to a host socket:
accept=80 # tunnel address:80 -> the device's LAN address:80
accept=22
accept=8554:127.0.0.1:554 # explicit target
- Without
IP:PORTthe target is the same port on the address of the default-route interface, looked up at each connection: the device's own LAN address. Its services see a LAN client, so a web interface that trusts127.0.0.1still asks for the password.127.0.0.1has to be named explicitly. - Up to 4 lines; command-line
--acceptcome before the file's. Inbound connections share the proxy's connection limit (24); over it, and when the local port refuses or does not answer within 15 s, the connection from the tunnel is reset. - Each connection is logged (
accept 80 -> 192.168.1.10:80: connected, or the error) and counted in the proxy statistics. - The service:
vtesse --installwritesaccept=80,accept=22andaccept=554to a configuration withoutacceptlines, and a client started from the U-Boot environment withoutacceptlines opens the same ports: the server reaches the camera's web interface, ssh and RTSP, as the TAP backend of 1.x gave it.accept=none(or--accept none) turns that off: no inbound forwards, and--installadds nothing; it cannot be combined with otheracceptlines. - On a
type tunsession the server needs the client's address before it can connect: set it withip=, or let the client learn it from the first connection (ip=learn, manual IPv4).
vtesse --install installs the running binary as a service (#50, #51, #52):
curl -fsSL -o /tmp/vtesse https://github.com/OpenIPC/vtesse/releases/latest/download/vtesse-arm32 &&
chmod 755 /tmp/vtesse && /tmp/vtesse --install --host vtun.example.org --password secret
vtesse --uninstall-
It detects the system, or takes
--system:System Detected by Binary Service openwrt/etc/openwrt_release/usr/bin/vtesse/etc/init.d/vtesse(procd),/etc/rc.d/S99vtesse,/lib/upgrade/keep.d/vtesseopenipcOPENIPC_VERSIONin/etc/os-release/usr/bin/vtesse/etc/init.d/S99vtessesystemd(a Linux PC)/run/systemd/system/usr/local/bin/vtesse/etc/systemd/system/vtesse.servicebusyboxBusyBox init: /etc/init.d/rcSrunsS??*(not OpenRC)/usr/bin/vtesse/etc/init.d/S99vtesse -
It writes
/etc/vtesse.conf(mode 600) from the keys--host,--port,--password,--sessionor--id-from,--ip,--gw(configuration file). An existing file keeps its other lines and gets the given keys replaced; a new one needs at least--hostand--passwordand getsport=5000and, without--session,id_from=the interface of the default route (it must have a MAC; with--rootthe identity must be given, as this machine's interfaces say nothing about the image).--password -reads the password from standard input instead of the command line (which other users can see inps). Without a configuration and without keys nothing is installed. A configuration withoutacceptlines getsaccept=80,accept=22andaccept=554(inbound forwards). -
It copies itself, writes the service file from its built-in copy and (re)starts the service;
--no-startonly installs. Only the binary, the service file and the configuration are written. -
On OpenWrt
sysupgradekeeps only the changed files under/etc: the keep list in/lib/upgrade/keep.d/vtessecarries the binary and the init script over. -
Updating a 1.x installation removes the hook scripts of the TAP backend (in
/usr/libexec/vtesse,/usr/local/libexec/vtesse, and/usr/share/vtesseup to 1.13). An/etc/default/vtessethat still passes--tap,--ifnameor--hookis reported and left alone: the client refuses those options, so the service does not start until they are removed.--scriptsis refused. -
Values are checked against the limits of the service: no spaces around a value, a password of up to 127 characters, a session name of up to 42 characters without spaces or colons. A kept
/etc/vtesse.confis set to mode 600 as well.
Running --install again updates the binary and the service file (the upgrade path)
and the given keys. --uninstall stops the service and removes everything --install
may have written, in any of the layouts, /etc/vtesse.conf and /etc/default/vtesse
included; if the service does not stop, nothing is removed. On a clean system it is not
an error. --root DIR does the same below DIR without touching the service
(tests, preparing a firmware image); an empty DIR or / is refused. A system with
/etc/init.d but another init (OpenRC, sysvinit) is not guessed: name it with --system.
The service files are also in scripts/: S99vtesse
(start|stop|restart|status, overrides in /etc/default/vtesse), vtesse.init (procd,
respawn) and vtesse.service (systemd, restart).
For quick remote access by support staff, the settings can live in the U-Boot environment instead of a file, the way OpenIPC keeps its VTun server there (#64):
fw_setenv vtesse vtun.example.org # on: the service starts at boot with this server
fw_setenv vtesse # offWith only vtesse set, the client uses the defaults of OpenIPC's VTun client
(tunnel),
so it reaches the same server with the same credentials:
- port
5000; - the interface of the default route (it must have a MAC), its MAC in upper case;
- session = that MAC without colons (the device ID);
- password =
echo "$MAC" | sha1sum: SHA-1 of the MAC with colons and a newline.
The client first waits for a default route; a route through an interface
without a MAC (PPPoE, a tunnel) means eth0. vtesse_session and vtesse_id_from
exclude each other; with both set the client refuses to start.
Optional variables override these and the file, one key at a time:
| Variable | Key |
|---|---|
vtesse |
host |
vtesse_port |
port |
vtesse_session / vtesse_id_from |
session / id_from |
vtesse_password |
password |
vtesse_ip, vtesse_gw |
ip, gw |
Everything else (route, udp_relay, accept, SOCKS5 users, auth) is set only in
/etc/vtesse.conf. Without accept lines there, a client started with vtesse set
opens ports 80, 22 and 554 to the server (inbound forwards): the
camera's web interface, ssh and RTSP on its own LAN address. vtesse_ip=learn takes
the address from the first packet of a type tun session. The order for each key: command line, U-Boot environment,
/etc/vtesse.conf, the VTun defaults (only with vtesse set). The variables are read
with fw_printenv; without it (a PC, most OpenWrt routers) this step is skipped.
--no-env ignores them; --show-config prints the result.
- The service scripts start without
/etc/vtesse.confwhenvtesseis set. vtesse --installwithout a configuration installs the binary and the service whenvtesseis set; keys given to--installare also stored withfw_setenvwhen it is available (vtesse_sessionandvtesse_id_fromreplace each other), and a failedfw_setenvstops the install. Withoutfw_setenvthe keys go to/etc/vtesse.confand the server stays in the environment; a key whose variable is already set is refused, as the variable would override it. Whilevtesseis set, the file needs no server or password and gets no defaults. With--rootthis machine's environment is not used.vtesse --uninstallleaves the variables alone, so a reinstall picks them up again.
Anyone with root or a flash dump can read vtesse_password, and the default password
is derived from the MAC: this mode is for helpdesk access and is no stronger than VTun
on OpenIPC (security).
key=value lines, # starts a comment. Credentials are read only from this file,
never from the command line; keep it outside the repository with mode 600.
| Key | Meaning |
|---|---|
host |
VTun server name or IPv4 address |
port |
VTun server TCP port |
session |
session name = device ID |
id_from |
instead of session: take the device ID from the MAC of this interface, e.g. eth0 |
udp_relay |
PORT[:LOCAL_PORT], repeatable: UDP relay |
accept |
PORT[:IP:PORT], repeatable, up to 4: inbound forwards; none: none at all, not even the service defaults |
ip, gw, route |
manual IPv4: ip=A.B.C.D/P or ip=learn, optional gw=A.B.C.D, route=NET/P (repeatable, up to 16); required for type tun |
password |
session password |
auth |
challenge response, xor (default; servers built without OpenSSL) |
socks_user, socks_password |
optional SOCKS5 username/password (RFC 1929) |
The session name is the device ID: a MAC address written as 12 characters, upper
case, 0-9A-F, no separators (e.g. 02AB34CD56EF). The same value is used as the
client's Ethernet MAC in type ether sessions.
- Validation: exactly 12 characters from
0-9A-F; the least significant bit of the first byte must be 0 (a multicast address cannot be a source MAC). The CLI accepts other session names too and then derives a locally administered MAC (with a warning). - Embedded Linux: the device's own MAC, so the ID needs no configuration per
device:
id_from=eth0in the file (or--id-from eth0) reads/sys/class/net/eth0/addressand uses it as the session name, e.g.a4:5e:60:12:34:5c→A45E6012345C. The CLI printsdevice ID … (MAC of eth0)and refuses a multicast or all-zero MAC.sessionandid_fromare mutually exclusive in the file. - Android: the hardware MAC is not available to apps — a random locally administered
unicast ID is generated once and stored (second character
2,6,AorE), or the ID is entered manually. A session with that name must exist on the server.
For type ether sessions bridged to a segment with a DHCP server, VTesse runs the
lwIP DHCP client through the tunnel:
- options used: 1 (netmask), 3 (router), 51/58/59 (lease), 121 (classless static routes, RFC 3442), 249 (Microsoft variant of 121); 6 (DNS) and 28 are informational;
- per RFC 3442, when option 121 is present option 3 is ignored → there is no default route through the tunnel; only the connected subnet and the 121 routes are reachable, other destinations are refused by the proxy (SOCKS5 code 3);
chaddris the device ID, so a reconnect gets the same lease (client-id, option 61, is not sent);- the server may probe the offered address with ARP before answering (~3 s);
- leases are not released on tunnel loss; on reconnect the previous address is requested again;
- until an address is bound the proxy refuses connections (SOCKS5 code 3).
A type tun session carries bare IPv4 packets (no Ethernet, no ARP), so there is no
DHCP: the address and routes are configured. The server side typically has
ip "addr add 10.76.0.1 peer 10.76.0.2 dev %%"; the client then uses:
ip=10.76.0.2/32
gw=10.76.0.1
route=10.78.0.0/24 # networks behind the server, repeatable
routelines are the reachable networks, like option 121; without them and withgwthere is a default route through the tunnel.- Applied when the tunnel comes up; the CLI prints
address … (manual). The same keys work on atype ethersession instead of DHCP.
ip=learn (or --ip learn, #68): VTun does not hand out addresses, so with a manual
address every device needs its own configuration. A stock server keeps the address in
the session's up {} and routes only that address into the session, so the
destination of the first unicast IPv4 packet from the server is ours, and its source is
the server's end:
- the address (
/32) and the peer are set before that packet enters the stack, so a first SYN (an inbound forward) is accepted; - broadcast, multicast and
0.0.0.0destinations are ignored; - after a reconnect the next packet is checked again: a changed address replaces the old one, the same address changes nothing;
routelines apply as with a manual address; without them the default route points into the link (the peer).gwis refused (the peer is learnt);- until the address is known, outbound use (SOCKS5, forwards) fails as without DHCP;
the CLI prints
waiting for the first packet from the server for the address, thenaddress … (learnt), peer …; type tunonly: on atype ethersession the client stops (exit code 2) and DHCP is the way. To make the address known right after connect, the server can send a packet fromup {}(e.g.program "ping -c1 <address>").- Acceptance:
bench/bench.sh tun-test(both backends).
Host sockets on 127.0.0.1 (configurable) relayed to TCP connections through the
tunnel. Outbound TCP and IPv4 only.
-
SOCKS5 (RFC 1928)
CONNECT; no authentication by default, optional username/password (RFC 1929). Reply codes:Code When 3network unreachableno route via the tunnel, or no DHCP address yet 4host unreachableconnect timeout (15 s) 5connection refusedthe destination port is closed 7command not supportedanything but CONNECT8address type not supportedhost names (no DNS in the stack) and IPv6 A domain field holding an IPv4 literal is accepted (
socks5hclients). -
Port forwards
LPORT:IP:RPORTfor players without proxy support (RTSP 554, HTTP 80). RTSP must use RTP over TCP (interleaved); RTP over UDP cannot pass a TCP forward. A forward to an unreachable destination closes the local connection. -
At most 24 connections at a time (bounded by lwIP TCP PCBs); further connections are accepted and closed.
-
Any local process can use an unauthenticated proxy on
127.0.0.1— setsocks_user/socks_passwordwhere that matters.
A camera can push its video as RTP over UDP instead of being asked over RTSP (OpenIPC
Majestic outgoing). The port forwards are TCP only, so VTesse has a separate UDP
relay (#28): datagrams sent to the client's tunnel address on a port are passed,
unchanged, to 127.0.0.1 on the device, where a player listens.
vtesse-cli --config /etc/vtesse.conf --udp-relay 1559
# address 10.77.0.169 ...
# udp relay 10.77.0.169:1559 -> 127.0.0.1:1559On the camera (/etc/majestic.yaml, then restart Majestic), with the tunnel address the
client printed. The key depends on the Majestic version — the camera's
/api/v1/config.schema.json shows which one it has:
outgoing: # one destination (e.g. Majestic of 2025)
enabled: true
server: udp://10.77.0.169:1559outgoing: # a list (newer Majestic)
servers:
- udp://10.77.0.169:1559The Majestic log then shows Outgoing RTP stream udp://…. Restarting Majestic over SSH
on OpenIPC firmware: keep the environment of the boot scripts, otherwise the sensor
may not be found and video stops (Cannot start SDK):
SENSOR=$(fw_printenv -n sensor) /etc/init.d/S95majestic restart (or restart from
the web UI, or reboot).
Player: udp://@:1559 (OpenIPC Decoder: a udp:// slot or its U cell with port 5600
via --udp-relay 1559:5600).
- One direction only; nothing is sent back to the camera. Up to 3 relays.
- Inside the TCP tunnel datagrams arrive reliably and in order (TCP retransmits); one is dropped only if the local socket's buffer is full. Large datagrams arrive as IP fragments and are reassembled.
- The tunnel address is stable: the DHCP lease is kept and requested again after a reconnect, so the camera's setting stays valid.
- Statistics at exit:
udp relay :1559: datagrams N, B bytes, dropped D. - Android: per-server option on port S559 (1559, 2559, 3559), see android/README.md.
- Acceptance:
bench/bench.sh udp-test.
Link events printed by the CLI (down = retried automatically with backoff 1–30 s,
failed = final):
| Message | Kind | Meaning |
|---|---|---|
| wrong session name or password | failed | server replied ERR (unknown session, wrong password, wrong auth mode, or session locked with multi no) |
| compression is enabled on the server | failed | session flags contain C/L |
| encryption is enabled on the server | failed | session flags contain E |
| unsupported transport (UDP) | failed | session flags contain U |
| unsupported session type | failed | type tty / type pipe |
| protocol error (not a VTun server?) | failed | unexpected greeting or flags |
| closed by server (possibly taken over by another connection) | down | CONN_CLOSE or EOF, e.g. another client logged in with the same session and multi killold |
| no response from server (keepalive) | down | 5 keepalive intervals without any frame |
| timeout | down | a handshake step timed out |
| cannot resolve / cannot connect to server | down | DNS or TCP connect failure |
tools/vtun_probe.py — Python 3, no dependencies. Performs the handshake (XOR
auth), prints the greeting and flags, answers keepalives, optionally sends one
DHCPDISCOVER through a type ether session and decodes the offer (including
option 121), then closes with VTUN_CONN_CLOSE.
tools/vtun_probe.py [--config ~/.config/vtesse/probe.conf] [--dhcp] [--delay S] [--seconds N]It reads the same configuration file (host, port, session, password). With
multi killold, probing with a production session name kicks the real device off —
use a dedicated test session.