A lightweight Go L2TPv2 server. The current server supports raw (unencrypted) L2TP, PPP LCP, MS-CHAPv2 and PAP authentication, IPCP, IPv4, and a native TUN data path on Linux and macOS.
The protocol and server packages are also usable as a public Go module. Network frontends can inject their own packet connections and consume authenticated sessions as raw IPv4 packet streams without enabling TUN or host routing. See Embedding l2tp-go.
The server includes an IKEv1/IPsec implementation that has been successfully tested with the built-in L2TP/IPsec clients on Windows, Linux, and macOS using pre-shared-key authentication.
The implementation is functional but has not undergone an independent security audit or extensive production-scale testing. It is suitable for interoperability testing and controlled deployments, but should not yet be treated as a production security boundary.
The implementation intentionally rejects obsolete DH group 1 and MD5-based transforms.
Main Mode preserves the initiator's exact SA bytes for HASH_I and HASH_R,
validates proposal attributes, requires explicit AES key lengths, caches
byte-identical replies for retransmissions, and clears sensitive exchange state
after failure or expiry. It recognizes common Microsoft, DPD, and NAT-T vendor
identifiers. RFC 3947 NAT detection enables authenticated migration to UDP port
4500 when NAT is detected or NAT-T is explicitly preferred.
Quick Mode validates HASH(1) and HASH(3), generates selector-bound HASH(2),
derives independent inbound and outbound ESP key material, and installs an ESP
pair only after the initiator's final confirmation. Phase 2 proposals requesting
PFS are rejected until Quick Mode key exchange support is implemented.
Authenticated informational exchanges process Dead Peer Detection and IKE/ESP deletion messages. Proactive DPD probes retire a peer after three missed intervals. Client-initiated Quick Mode rekeys retain the previous ESP association during transition and automatically move active L2TP flows to the replacement association. Server-initiated Quick Mode is limited to three attempts with increasing delays; after that, the working child SA is retained while still valid and the server waits for a peer-initiated rekey. An established Phase 1 lifetime is a soft boundary: the responder keeps the working IKE SA until the client opens a replacement Main Mode SA. It transfers the existing child SA to the replacement without changing its SPIs, keys, sequence state, or remaining lifetime. Later Quick Mode rekeying remains independent of the Phase 1 switch. The server does not originate Main Mode, so server-initiated Phase 1 rekeying remains a limitation. Graceful server shutdown sends encrypted IKE deletion messages before closing the IKE and NAT-T listeners.
The current release supports multiple users and automatically leases client addresses from a configured IPv4 pool.
The recommended starting point is l2tp-go.toml.example. Run it natively with:
sudo ./l2tp-go --config ./l2tp-go.toml
The configuration is strict: unknown TOML keys and invalid values stop startup with a descriptive error. User credentials are ordinary TOML values for now:
[authentication]
method = "mschapv2"
[[authentication.users]]
username = "alice"
password = "replace-me"
[[authentication.users]]
username = "bob"
password = "replace-me-too"
Server and pool-start settings accept either a full IPv4 address or a host
offset within the subnet. For example, subnet 10.0.0.0/24, server address
1, pool start 100, and pool size 50 produce server 10.0.0.1 and leases
10.0.0.100 through 10.0.0.149.
[network]
subnet = "10.0.0.0/24"
server_address = "1"
client_pool_start = "2"
client_pool_size = 50
dns = "1.1.1.1"
mtu = 1400
Keep the real file out of source control and readable only by its owner. Legacy environment-variable overrides remain available for compatibility, but TOML is the primary and recommended configuration source.
Docker deployment is intended for a Linux Docker host with /dev/net/tun.
Docker Desktop on macOS does not expose a macOS utun device to Linux
containers; use the native macOS instructions below instead.
Multi-platform images for linux/amd64 and linux/arm64 are published to both
registries:
eukhlg/l2tp-go on Docker Hubghcr.io/eukhlg/l2tp-go on GitHub Container RegistryUse latest for releases and testing for the newest feature-branch build.
Copy the example configuration, set the PSK, server identity, and user
passwords, then start the released image:
cp l2tp-go.toml.example l2tp-go.toml
chmod 600 l2tp-go.toml
docker compose pull
docker compose up -d
docker compose logs -f l2tp
To test the newest feature-branch image or use GHCR instead, select the image before pulling:
export L2TP_IMAGE=eukhlg/l2tp-go:testing
# Or: export L2TP_IMAGE=ghcr.io/eukhlg/l2tp-go:testing
docker compose pull
docker compose up -d
For a local source build, bypass the registry with:
docker compose build
docker compose up -d
To keep the configuration at another path, set the Compose-only
L2TP_CONFIG_FILE variable:
L2TP_CONFIG_FILE=/srv/l2tp/server.toml docker compose up -d
Compose publishes UDP ports 1701, 500, and 4500, grants NET_ADMIN and
NET_RAW, mounts /dev/net/tun, and enables container-local forwarding and
NAT. Docker port publishing cannot expose raw IP protocol 50, so containers use
UDP/4500 NAT-T; native Linux deployments may use raw ESP. A graceful stop sends
StopCCN to active tunnels before shutdown.
Stop and remove the service with docker compose down.
Build the server:
go build -o l2tp-go ./cmd/l2tp-go
Set [tun].enabled = true in the TOML configuration. Command-line flags can
override TOML when needed.
sudo ./l2tp-go --config ./l2tp-go.toml
macOS (-tun-name may be omitted to select an available utun):
sudo ./l2tp-go --config ./l2tp-go.toml \
-tun-name ''
The process creates the native interface, configures its address and MTU, forwards client packets into it, reads return packets, and sends those packets back through the matching PPP session. It brings the interface down during a clean shutdown.
Native execution does not modify host IP forwarding or NAT. Configure those
with the host firewall/router policy appropriate for the machine. The Docker
entrypoint performs container-local NAT when [nat].enabled = true.
For protected operation, configure a client for L2TP/IPsec with a pre-shared key, the server's public IPv4 address, and MS-CHAPv2 PPP authentication. Permit UDP 500 and UDP 4500 through host and cloud firewalls; permit IP protocol 50 for clients that do not need NAT traversal.
For raw test operation, configure a client for L2TP without IPsec, with
MS-CHAPv2 authentication (or PAP when [authentication].method = "pap"), UDP
port 1701, and the configured username and password. Many operating-system VPN
interfaces require IPsec for L2TP and will not offer raw L2TP; use a client
such as xl2tpd/pppd that explicitly permits raw L2TP.
Successful startup does not prove that UDP 1701 is reachable from outside the host. Verify host and cloud firewalls separately.
Run tests with [server].debug = 1 and [ipsec].force_natt = true first. Test
the Windows built-in L2TP/IPsec client, NetworkManager-l2tp on Linux, and the
macOS built-in L2TP over IPsec profile where available. Each client should be
tested from a different NATed network and, when possible, from a directly
addressed host.
For every platform verify: Main Mode and Quick Mode completion, UDP/4500 migration, PPP authentication, assigned address and DNS, traffic through TUN, DPD acknowledgement, an hour-bound client-initiated rekey, manual disconnect, container restart/reconnect, and two simultaneous users. Capture the server side with:
docker compose logs -f --since=5s --no-color l2tp 2>&1 | tee l2tp-ipsec.log
If negotiation stops, preserve the complete log from the first ike rx line
through the rejection. The trace deliberately omits PSKs, passwords, key
material, and decrypted traffic, so it is suitable for interoperability bug
reports after reviewing public IP addresses.
If Compose restarts the container, inspect both its status and previous logs:
docker compose ps
docker compose logs --tail=200 l2tp
docker inspect --format '{{.State.ExitCode}} {{.State.Error}}' l2tp-go-l2tp-1
IPv6 and packets without an active PPP destination may appear on TUN. They are dropped without stopping the server. A fatal listener or TUN error is logged with the component name before the process exits.
For connection negotiation failures, enable tracing in the TOML configuration:
[server]
debug = 1
Then recreate the container and follow its output:
docker compose up -d --force-recreate
docker compose logs -f --tail=200 l2tp
Debug level 1 records IKE cookies/exchanges and
state, proposal selection, NAT-D and migration decisions, Quick Mode SPIs and
lifetimes, authentication, DPD sequences, ESP rejection reasons, connections,
disconnections, and lifecycle events. Stable prefixes include DEBUG ike, DEBUG natt, DEBUG quick,
DEBUG esp, and DEBUG ipsec lifecycle. It does not record PSKs, passwords,
DH private values, derived keys, authentication responses, or plaintext packet
payloads. Level 2 additionally emits TRACE entries for every ESP, L2TP, PPP,
and IPv4 packet and should be enabled only for short captures because it can
produce logs faster than Docker can display them.
Successful PPP session events are always written to standard output at every
debug level. Each connect/disconnect line includes the authenticated username,
assigned VPN address, transport (l2tp or l2tp/ipsec), peer address, tunnel
ID, and session ID. Sessions that fail before authentication and IPCP open do
not produce a misleading connected/disconnected pair.
go test ./...
go test -race ./...
Native TUN creation is an opt-in privileged integration test:
sudo go test -tags=integration ./tun -run TestOpenNativeDevice -v
Content type
Image
Digest
sha256:6329086f4…
Size
7 MB
Last updated
about 1 month ago
docker pull eukhlg/l2tp-go