A DPI-resistant VPN designed to work in heavily censored networks.
Related repositories: tiredvpn/tiredvpn-android — Android client
TiredVPN is a censorship-resistant VPN that uses multiple bypass strategies to establish and maintain connectivity in networks with active Deep Packet Inspection (DPI). Instead of relying on a single obfuscation technique, it implements an adaptive strategy engine that automatically selects the best transport based on current network conditions.
The system probes available strategies, ranks them by latency and reliability, and falls back to alternatives mid-session if the active strategy gets blocked. The same applies one level down: a client can be given a list of servers, and the address family and the server are both re-decided at runtime rather than settled once at startup. This targets filtering systems like TSPU (Russia), GFW (China), and similar DPI infrastructure.
Key design goals:
- Resilience - no single point of failure. A blocked strategy, a dead IPv6 path, or an unreachable server each get replaced without a restart.
- Stealth - traffic patterns are morphed to resemble legitimate services (video streaming, CDN traffic, HTTPS browsing).
- Performance - multiplexed connections via smux and an optional QUIC transport cut per-request handshake overhead.
- 20+ DPI bypass strategies with automatic selection and mid-session fallback
- QUIC and TLS transports with Salamander padding and SNI fragmentation
- REALITY-style transport - the handshake carries a real browser's TLS fingerprint (uTLS) toward a donor SNI; the opt-in B1 mode is a genuine TLS 1.3 handshake, the default path is not full TLS past the ClientHello (see security model)
- HTTP/2 steganography - hides tunnel data inside real HTTP/2 frames
- Traffic morphing - statistically matches traffic patterns of popular services
- Geneva engine - country-specific packet manipulation rules (Russia, China, Iran, Turkey)
- Multiplexed connections via smux for efficient stream management
- TUN mode for full system traffic tunneling
- SOCKS5 and HTTP proxy modes
- Server pool - a list of endpoints in TOML with
priority,latency, orweightedselection, and per-candidate parking with exponential cooldown - Runtime transport fallback - the address family is re-decided during the session, not fixed at startup
- IPv6 inside the tunnel (dual-stack), on by default, with outbound IPv6 blocked when the exit cannot carry it so it does not leak around the VPN
- Port hopping with random, sequential, and Fibonacci strategies
- Encrypted Client Hello (ECH) to hide SNI from DPI
- Post-quantum TLS (X25519MLKEM768 hybrid) negotiated in the outer TLS, but only under the donor names that genuinely offer it - there is no separate client flag (see security model)
- Multi-hop routing through chained servers
- TOML configuration with CLI overrides (
-config) - Native packages -
.deb/.rpm, signed apt/yum repositories, one-line installer, and a systemd unit that brings up forwarding and NAT itself - Prometheus-compatible metrics endpoint
- Android integration via JNI (c-shared build mode)
- Docker and Helm - multi-arch images and a chart on ghcr.io
- Client management with Redis backend and REST API
- QR code generation for mobile client provisioning
curl -fsSL https://tiredvpn.github.io/tiredvpn/install.sh | sudo bash -s -- --port 443Installs the server, generates a secret and TLS cert, starts the service, and
prints the connection string plus a QR code for the mobile app. Pick a different
port with --port N (default 443).
curl -fsSL https://tiredvpn.github.io/tiredvpn/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/tiredvpn-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/tiredvpn-archive-keyring.gpg] https://tiredvpn.github.io/tiredvpn/apt stable main" | sudo tee /etc/apt/sources.list.d/tiredvpn.list
sudo apt update && sudo apt install tiredvpn
sudo tiredvpn-init # generates secret/cert, prints keys, starts the servicesudo tee /etc/yum.repos.d/tiredvpn.repo >/dev/null <<'EOF'
[tiredvpn]
name=TiredVPN
baseurl=https://tiredvpn.github.io/tiredvpn/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://tiredvpn.github.io/tiredvpn/gpg.key
EOF
sudo dnf install tiredvpn && sudo tiredvpn-initThe apt and dnf packages install the service in a disabled state - they
ship the binary but leave it stopped. Run sudo tiredvpn-init to finish setup:
it generates the secret and cert, prints the access keys, and starts the
service. The one-liner and install.sh already do this for you.
By default the service comes up in TUN mode (TIREDVPN_IP_POOL=10.8.0.0/24)
and sets ip_forward + NAT for the pool on each start, so the Android app (which
always uses native TUN) works out of the box - no manual forwarding. For
proxy-only, use install.sh --proxy-only or tiredvpn-init --proxy-only. Pick a
different pool with tiredvpn-init --ip-pool <CIDR>. Details:
Server firewall and forwarding.
For other distros or hosts without systemd, download the binary directly:
base=https://github.com/tiredvpn/tiredvpn/releases/latest/download
curl -LO $base/tiredvpn-linux-amd64.tar.gz
curl -LO $base/checksums.txt
grep tiredvpn-linux-amd64.tar.gz checksums.txt | sha256sum -c -
tar xzf tiredvpn-linux-amd64.tar.gz
sudo mv tiredvpn-linux-amd64 /usr/local/bin/tiredvpnAvailable platforms: linux-amd64, linux-arm64. This gives you the binary
only - no systemd unit and no tiredvpn-init; use install.sh --method binary
if you want those too. Other installation methods are described in the
Docker and Building from Source sections
below.
Kubernetes via Helm (server and/or client from a single release):
helm install my-tiredvpn oci://ghcr.io/tiredvpn/charts/tiredvpn --version 0.3.0 \
-f my-values.yamlThe chart is versioned separately from the binary: chart 0.3.2 ships
appVersion 1.5.1. Each tagged release patch-bumps the chart and pushes it to
the OCI registry.
See deploy/helm/tiredvpn/README.md for values, examples, and TLS/auth/Redis/HPA options.
The steps below (secret, certs, server flags) are for manual or advanced setups only. The one-liner and the apt/dnf packages handle all of this for you via
tiredvpn-init- skip ahead to the app sections if you used one of those.
openssl rand -hex 32For testing, create a self-signed certificate:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-days 365 -nodes -keyout server.key -out server.crt \
-subj "/CN=your-server.com"For production, use Let's Encrypt with certbot:
sudo certbot certonly --standalone -d your-server.com
# Certificates will be at:
# /etc/letsencrypt/live/your-server.com/fullchain.pem
# /etc/letsencrypt/live/your-server.com/privkey.pemtiredvpn server \
-listen :443 \
-cert server.crt \
-key server.key \
-secret <your-secret>The server listens on both TCP (TLS) and UDP (QUIC) by default. It serves a fake website to unauthenticated visitors, making it indistinguishable from a normal HTTPS server.
Automatic, for every way of running the server - package, Docker, or the
raw binary. Whenever -ip-pool is set (the packaged unit sets it by default:
TIREDVPN_IP_POOL=10.8.0.0/24 in /etc/tiredvpn/env), the server flips
net.ipv4.ip_forward=1 and installs MASQUERADE + FORWARD nftables rules
for the pool on the WAN interface (autodetected via the route to 1.1.1.1;
override with the TIREDVPN_WAN_IFACE env var) directly over netlink on
start - no iptables/ip binaries, no wrapper script, nothing to install.
This is what lets the Android app - which always uses native TUN - connect to
a fresh install with no manual steps. Failure is non-fatal: a host without
CAP_NET_ADMIN logs a warning and comes up proxy-degraded rather than
refusing to start.
To opt out and run proxy-only: install.sh --proxy-only /
tiredvpn-init --proxy-only, or set TIREDVPN_IP_POOL= (empty) in
/etc/tiredvpn/env and restart. Custom CIDR: tiredvpn-init --ip-pool <CIDR>.
The automatic setup is IPv4-only. For an IPv6 pool, or if you'd rather
manage NAT yourself (a shared box with its own firewall policy, etc.), the
automatic MASQUERADE/FORWARD install only ever touches the -ip-pool CIDR, so
your own rules just coexist with it:
# Find the uplink with: ip route get 1.1.1.1 (the "dev <iface>" part)
ip6tables -t nat -A POSTROUTING -s <ipv6-pool> -o <wan-iface> -j MASQUERADE
ip6tables -A FORWARD -s <ipv6-pool> -j ACCEPT
ip6tables -A FORWARD -d <ipv6-pool> -j ACCEPT
sysctl -w net.ipv6.conf.all.forwarding=1tiredvpn client \
-server your-server.com:443 \
-secret <your-secret> \
-listen 127.0.0.1:1080This starts a local SOCKS5 proxy on port 1080. Point your browser or
applications to socks5://127.0.0.1:1080.
sudo tiredvpn client \
-server your-server.com:443 \
-secret <your-secret> \
-tun \
-tun-routes 0.0.0.0/0This creates a TUN interface and routes all traffic through the VPN.
Since 1.5.0 -tun-ipv6 defaults to dual: IPv6 goes through the tunnel when
the exit was started with -ip-pool-v6, and is rejected outright when it was
not, so applications cannot reach the internet over a native IPv6 default route
while the VPN is up. -tun-ipv6 off restores the pre-1.5.0 behaviour, which is
one answer for a split tunnel - on its own, dual sends all IPv6 into the
tunnel, including destinations you deliberately routed around it on IPv4. The
other answer is -tun-routes6, which mirrors the v4 split on the v6 side
instead of giving IPv6 up. The blocking half is Linux-only; on macOS it warns
instead.
If the host carries IPv6 of its own - a 6in4 tunnel, another VPN, a routed
prefix - name it with -tun-ipv6-allow he6,2001:db8::/32 (interface names,
prefixes, or both) and the block leaves it alone.
That covers the filter. The routing is a separate lever: dual also installs
::/1 + 8000::/1, which beat the host's own default on prefix length and take
IPv6 over entirely. On a node that must keep routing its own IPv6, pass
-tun-routes6 none - the tunnel still gets a v6 address, but claims no
destination, and you route into it from your own tables exactly as you already
do for IPv4 with an absent -tun-routes. A prefix list works too
(-tun-routes6 2001:db8::/32). Unset keeps the full tunnel, so a plain client
is unaffected.
TiredVPN takes configuration from CLI flags, from a TOML file (-config), or
from both. Precedence is CLI flag > TOML > default, and a flag counts as
set only if it was passed explicitly - leaving it at its default value never
overwrites a TOML field.
tiredvpn client -config /etc/tiredvpn/client.toml
tiredvpn server -config /etc/tiredvpn/server.tomlAnnotated examples: configs/client.example.toml
and configs/server.example.toml. Unknown keys
are rejected, so a typo fails at startup instead of being ignored.
TOML does not yet cover the whole flag surface. On the client it carries the
server list, the selection policy, strategy.mode, tls.fingerprint, the
shaper and the log level; on the server, the listen address, the certificate
and key, and the log level. Everything else - -secret, the -tun-* flags,
the evasion knobs - is still flags only. The full mapping and the deprecation
plan are in
internal/config/toml/MIGRATION.md.
Run tiredvpn server -help or tiredvpn client -help for the full flag list.
| Flag | Default | Description |
|---|---|---|
-config |
Path to a TOML config; CLI flags override it | |
-listen |
:443 |
IPv4 listen address |
-listen-v6 |
IPv6 listen address (default: [::] on the -listen port) |
|
-dual-stack |
true |
Listen on both IPv4 and IPv6 |
-cert |
server.crt |
TLS certificate file |
-key |
server.key |
TLS private key file |
-secret |
Shared secret (single-client mode) | |
-redis |
Redis address for multi-client mode | |
-redis-db |
0 |
Redis logical database, 0-15 (TIREDVPN_REDIS_DB) |
-redis-prefix |
tiredvpn: |
Redis key namespace (TIREDVPN_REDIS_PREFIX) |
-api-addr |
127.0.0.1:8080 |
REST API for client management |
-api-token |
Bearer token for the management API (TIREDVPN_API_TOKEN) |
|
-ip-pool |
CIDR for TUN client IP assignment | |
-ip-pool-v6 |
IPv6 ULA prefix for dual-stack TUN clients | |
-tun-mtu |
1280 |
TUN interface MTU (1280-9000) |
-port-range |
Multi-port listening (e.g. 47000-47100) |
|
-no-quic |
false |
Disable QUIC/UDP listener |
-upstream |
Upstream server for multi-hop | |
-fake-root |
./www |
Directory served to unauthenticated visitors |
-enable-icmp |
false |
Enable ICMP tunnel listener (requires CAP_NET_RAW) |
-reality-b1 |
false |
Accept the REALITY B1 transport; needs -reality-private-key |
-reality-private-key |
Static X25519 key, base64 (TIREDVPN_REALITY_PRIVATE_KEY) |
|
-reality-fallback-tag |
false |
With -reality-b1: let the TLS fallback transports' tagged hellos reach their handlers instead of the donor. Use per-client registry secrets with it; see docs/server.md |
-max-conns |
0 |
Cap on in-flight incoming connections (0 = 4096) |
-pprof |
Serve pprof on this address (e.g. :6060) |
|
-debug |
false |
Verbose logging |
Generate the B1 key pair with tiredvpn reality-keygen.
| Flag | Default | Description |
|---|---|---|
-config |
Path to a TOML config; CLI flags override it | |
-server |
Remote server address (required unless the config lists servers) | |
-server-v6 |
Same server over IPv6 (e.g. [2001:db8::1]:995) |
|
-server-policy |
Order of the [[servers]] list: priority, latency, weighted (empty = priority) |
|
-secret |
Shared secret (required) | |
-listen |
127.0.0.1:1080 |
Local SOCKS5/HTTP proxy address |
-http-listen |
Separate HTTP proxy address | |
-tun |
false |
Enable TUN mode (full VPN) |
-tun-routes |
Routes to tunnel (e.g. 0.0.0.0/0) |
|
-tun-ipv6 |
dual |
IPv6 while the tunnel is up: dual, block, off |
-tun-ipv6-allow |
Exceptions to the IPv6 block: interface names and/or prefixes, comma-separated | |
-tun-routes6 |
IPv6 destinations the tunnel claims; none leaves the host's own IPv6 routing alone |
|
-tun-mtu |
1280 |
TUN MTU; with -auto-mtu this is the upper bound |
-auto-mtu |
true |
Probe the real end-to-end MTU and apply min(probed, -tun-mtu) |
-quic |
false |
Enable QUIC transport |
-strategy |
Force a specific strategy (-list prints them) |
|
-seqovl-packet |
false |
Packet-level TCP sequence overlap for seqovl (Linux + CAP_NET_ADMIN) |
-port-hop |
false |
Enable port hopping |
-ech |
false |
Enable Encrypted Client Hello |
-rtt-masking |
false |
Hide proxy timing signature |
-shaper |
Traffic shaper preset for the morph strategies | |
-prefer-ipv6 |
true |
Prefer IPv6 transport |
-fallback-v4 |
true |
Fall back to IPv4 if IPv6 fails |
-fallback |
true |
Mid-session strategy fallback |
-benchmark |
false |
Run strategy latency benchmark |
-debug |
false |
Verbose logging |
A list of servers is config-file only - there is no flag that takes more than
one address. [server] and [[servers]] are mutually exclusive (a lone
[server] is a one-element list), and passing -server collapses whatever
the file says to a single entry.
[[servers]]
name = "ams"
address = "203.0.113.10"
port = 443
address_v6 = "2001:db8::10"
weight = 100
[[servers]]
name = "fra"
address = "203.0.113.20"
port = 443
weight = 50
[selection]
policy = "priority" # priority (list order) | latency | weighted
family = "prefer_v6" # prefer_v6 | prefer_v4 | v6_only | v4_only
failure_threshold = 2 # failed connect cycles before a candidate is parked
cooldown = "1m" # first cooldown; doubles per repeat, jittered
max_cooldown = "30m"
min_dwell = "5m" # hold a fallback this long before going back
health_check = "off" # off | activeA dial target is an (endpoint, family) pair, so moving from IPv6 to IPv4 and moving between servers are the same operation. Background health checking is off by default on purpose: polling every server on a timer is a periodic fan-out pattern with nothing behind it. With it off, the client learns a server is up by dialling it.
# Add a client (multi-client mode with Redis); -name is required
tiredvpn admin add -api http://127.0.0.1:8080 -server vpn.example.com:443 -name alice
# List clients
tiredvpn admin list -api http://127.0.0.1:8080
# Delete a client
tiredvpn admin delete -api http://127.0.0.1:8080 -id <client-id>
# Generate QR code for mobile app
tiredvpn admin qr -server vpn.example.com:443 -secret <secret>If the server runs with -api-token, pass the same value as -api-token to
the add, list, and delete subcommands (or set TIREDVPN_API_TOKEN).
Pre-built images are available on Docker Hub. Platforms: linux/amd64, linux/arm64.
| Tag | Description |
|---|---|
latest |
Latest stable release |
1.5.1 |
Pinned version (one tag per release) |
edge |
Latest main branch build |
docker run -d \
--name tiredvpn-server \
-p 443:443/tcp \
-p 443:443/udp \
-v /path/to/certs:/certs:ro \
tiredvpn/tiredvpn:latest \
server -listen :443 \
-cert /certs/server.crt \
-key /certs/server.key \
-secret <your-secret>curl -O https://raw.githubusercontent.com/tiredvpn/tiredvpn/main/docker-compose.yml
TIREDVPN_SECRET=<your-secret> docker compose up -dOr with a custom docker-compose.yml:
version: "3.8"
services:
tiredvpn-server:
image: tiredvpn/tiredvpn:latest
ports:
- "443:443/tcp"
- "443:443/udp"
volumes:
- ./certs:/certs:ro
command:
- "server"
- "-listen"
- ":443"
- "-cert"
- "/certs/server.crt"
- "-key"
- "/certs/server.key"
- "-secret"
- "${TIREDVPN_SECRET}"
- "-redis"
- "redis:6379"
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
restart: unless-stopped
volumes:
redis-data:docker build -t tiredvpn .The default image handles server-side TUN (-ip-pool) out of the box - the
binary sets up NAT itself via netlink/nftables on start, no special build
needed. It just needs /dev/net/tun, NET_ADMIN, and a writable
net.ipv4.ip_forward from the container runtime. The same applies to Helm via
server.tun.enabled. Full docker run / compose / Helm recipes are in
docs/deployment.md → TUN mode in containers.
- Go 1.26 or later
- Linux or macOS (Windows is not supported: the core does not build for it)
git clone https://github.com/tiredvpn/tiredvpn.git
cd tiredvpn
make build # -> ./tiredvpnOr directly with Go (this skips the -X main.version ldflag, so
tiredvpn -version reports dev):
go build -o tiredvpn ./cmd/tiredvpn/| Target | Output |
|---|---|
make build-linux |
tiredvpn-linux-amd64 |
make build-android |
libtiredvpn.so (arm64 c-shared, needs CGo) |
make build-macos-cli |
tiredvpn-macos-arm64, tiredvpn-macos-amd64 |
make build-macos-lib |
build/macos/libtiredvpn.a (universal; macOS host only) |
make test # go test -race ./internal/...
make lint # golangci-lint run ./...TiredVPN includes an adaptive strategy engine that automatically selects the
best transport. Each strategy targets a different aspect of DPI evasion.
tiredvpn client -list prints the ones registered for your current flags, in
priority order; -strategy <id> pins one.
The Server auth column says whether the transport authenticates the server
to the client. no marks a camouflage-only transport: it dials with
InsecureSkipVerify and the server proves nothing an active man-in-the-middle
could not fake, so a MITM that terminates the connection in the path can pose as
the exit and read what you tunnel into it. These defeat blocking, not a targeted
interceptor; for that threat model use REALITY B1 (-reality-b1). Application
HTTPS inside the tunnel is a separate layer and still protects you. See the
security model.
| ID | Name | Server auth | Description |
|---|---|---|---|
reality |
REALITY Protocol | yes | Hides auth inside a uTLS browser-fingerprinted ClientHello toward a donor SNI. Default path is not full TLS past the handshake, but the server proves itself with a secret-keyed ack bound to the client's fresh key; B1 (-reality-server-pubkey) is a genuine TLS 1.3 handshake (first in the default order) |
quic_salamander |
QUIC Salamander | yes | QUIC over UDP with Salamander padding (opt-in: -quic) |
quic |
QUIC Tunnel | yes | QUIC v1 transport with SNI fragmented across CRYPTO frames to bypass SNI-based blocking (opt-in: -quic -quic-sni-frag) |
seqovl |
Seqovl (Sequence Overlap) | yes | Prepends a secret-marked decoy TLS record before the REALITY ClientHello to desync stateful DPI reassembly (packet-level overlap on Linux via -seqovl-packet); server auth inherited from REALITY |
http2_stego |
HTTP/2 Steganography | no | Hides data inside HTTP/2 frames with NaiveProxy-style padding. Server ack is secret-keyed but not bound to the TLS session, so a captured ack can be replayed - camouflage-only |
websocket_padded |
WebSocket Salamander | no | WebSocket transport with Salamander obfuscation padding. Server replies with an HTTP 101 only - camouflage-only |
http_polling |
HTTP Polling | no | Short-lived HTTP/1.1 requests (meek-style), evades long-connection detection. Server replies with a plain OK - camouflage-only |
morph_* |
Traffic Morphing | no | Reshapes traffic to match video streaming profiles (Yandex, VK, Baidu, Aparat). Server replies with a 1-byte ack - camouflage-only |
confusion_* |
Protocol Confusion | yes | Makes packets appear as DNS/HTTP/SSH/SMTP over TLS, plus a multi-layer variant; server marker keyed to the secret and a fresh nonce |
geneva_* |
Geneva Engine | no | Country-specific packet manipulation over WebSocket-padded. Registered by default: geneva_russia, geneva_china, geneva_iran. Turkey rules exist in the engine but are not registered as a strategy. Camouflage-only |
antiprobe |
Anti-Probe | yes | Server masquerades as normal website; reveals tunnel only to authenticated clients, and answers with an HMAC over the TLS session exporter |
state_exhaustion |
State Exhaustion | yes | Floods DPI state table with decoys to trigger fail-open mode; rides the Confusion transport |
mesh_relay |
Mesh Relay | no | Routes through relay nodes in regions with lighter filtering (opt-in, needs relay nodes configured). The relay does not prove itself to the client - camouflage-only |
icmp_tunnel |
ICMP Tunnel | yes | Backup tunnel over ICMP Echo (opt-in: -icmp-tunnel, requires CAP_NET_RAW and a server started with -enable-icmp); ChaCha20-Poly1305 with per-session keys from the secret |
ssh_camouflage |
SSH Camouflage | yes | Tunnel under SSH traffic (priority 28, requires server support); server auth token over the SSH session id |
imap_camouflage |
IMAP Camouflage | no | Tunnel under IMAP mail protocol (priority 29, requires server support). SASL CRAM-MD5 authenticates client to server only - camouflage-only |
The strategy engine supports:
- Automatic probing - tests all available strategies and ranks by latency
- Circuit breakers - disables failing strategies after configurable threshold
- Mid-session fallback - switches strategy without dropping the connection
- Periodic re-probing - re-evaluates blocked strategies on a timer
- Benchmarking -
tiredvpn client -benchmarkto test all strategies
cmd/tiredvpn/ CLI entrypoint (server, client, admin, reality-keygen)
internal/
server/ Server-side connection handling, TLS/QUIC listeners
client/ Client-side proxy, TUN, strategy orchestration
strategy/ DPI bypass strategies and adaptive engine
endpoint/ Server pool: candidates, selection policy, parking
tun/ TUN device management, routes, IPv6 policy
mux/ smux multiplexer integration
protocol/ Wire format and handshake versions
tls/ TLS utilities and uTLS fingerprinting
evasion/ Low-level evasion primitives
geneva/ Geneva packet manipulation engine
detect/ Network and blocking detection
porthopping/ Port hopping logic
pool/ IP address pool for TUN clients
shaper/ Behavioural traffic shaping (presets, distributions)
metrics/ Prometheus metrics collector
padding/ Traffic padding utilities
protect/ Android VpnService socket protection
control/ Android control socket protocol
capabilities/ Runtime capability probing
config/toml/ TOML schema, loader, and CLI-override resolver
log/ Structured logging
benchmark/ Strategy benchmarking
buildinfo/ Version and build metadata
integration/ Cross-package integration tests
ktls/ Kernel TLS offload
Full documentation is available in the docs/ directory:
- Getting Started
- Server Reference
- Client Reference
- Admin and Client Management
- DPI Bypass Strategies
- Architecture
- Deployment Guide
- Security Model
- Monitoring
Contributions are welcome. Please see CONTRIBUTING.md for guidelines on submitting patches, reporting issues, and the development workflow.
Before submitting a pull request:
- Run
make testand ensure all tests pass - Run
make lintif you have golangci-lint installed - Keep commits focused and well-described
- GitHub Discussions — feature ideas, Q&A
TiredVPN is licensed under the GNU Affero General Public License v3.0.




