Skip to content

Support custom (non-Tailscale) control planes end-to-end: TLS control, streaming map-poll, DERP - #22

Open
kentaro wants to merge 12 commits into
CamM2325:mainfrom
pepabo:feature/custom-control-plane-tls
Open

Support custom (non-Tailscale) control planes end-to-end: TLS control, streaming map-poll, DERP#22
kentaro wants to merge 12 commits into
CamM2325:mainfrom
pepabo:feature/custom-control-plane-tls

Conversation

@kentaro

@kentaro kentaro commented Jul 21, 2026

Copy link
Copy Markdown

Summary

This PR makes MicroLink actually connect to a custom, non-Tailscale control plane end-to-end. It started as "build-time control plane config + TLS control transport" and grew, through bringing up a real ESP32 node on a headscale-based deployment (Lolipop Zero Trust Link), to cover the three further places where MicroLink assumed Tailscale's SaaS: the initial map poll, DERP region selection, and DERP TLS.

The README documents Headscale/Ionscale support, but several parts were hardcoded to Tailscale (control host + Noise key, the Stream=false initial fetch, DERP region 9 / derp9e.tailscale.com). Each commit is independent and defaults to current behavior.

Commits

  1. Build-time custom control plane config with TLS transport
    CONFIG_ML_CTRL_HOST, and CONFIG_ML_CTRL_TLS to wrap the ts2021 Noise handshake in TLS on port 443 (new ml_coord_tls.c) for control planes exposed only over HTTPS. Cert not verified; the control plane is authenticated by the pinned Noise key, same trust model as the plain port-80 transport.

  2. ctrl_host / ctrl_noise_pubkey in microlink_config_t
    Runtime counterparts, so the host and its /key-fetched Noise key can be provisioned on-device rather than baked in.

  3. streaming_map_fetch for controllers that ignore Stream=false
    Some headscale control planes never respond to the initial Stream=false MapRequest (60s of zero bytes → empty MapResponse → reconnect loop). The native client starts a single Stream=true poll whose first message carries the full netmap. New flag (default false) switches to that: start long-poll with OmitPeers=false, read the initial netmap, parse Node/Peers/DERPMap, then connect DERP. Parsing is factored into shared helpers used by both paths.

  4. DERP connection for custom control planes — three fixes:

    • Region fallback: if the server's HomeDERP region isn't in the DERPMap (custom planes advertise their own region IDs), use the first available region instead of dialing the hardcoded derp9e.tailscale.com.
    • Robust TLS setup + no leak: check every mbedTLS setup return and abort before mbedtls_ssl_handshake. A failing mbedtls_ssl_setup (typically -0x7F00 ALLOC_FAILED under internal-RAM pressure) NULLs ssl->conf, so handshake then returned BAD_INPUT_DATA and the real cause (OOM) showed up as "Bad input parameters". Added derp_tls_abort() to free the mbedTLS context (~20KB TLS buffers) on every failure path; the old code only closed the socket and leaked per retry. (App-side companion: CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y to place TLS buffers in PSRAM.)
    • Pin DERP TLS to 1.2: on ESP-IDF mbedTLS the TLS 1.3 path can't parse an ECDSA cert's signature-algorithm OID even with VERIFY_NONE (TLS 1.3 can't skip cert processing). DERP relays accept TLS 1.2, so cap max version at 1.2.

Testing

Brought up an ESP32-S3 node on a headscale-based control plane (443/TLS-only, DERP relays behind Let's Encrypt ECDSA certs): control TLS + Noise + registration + streaming netmap + DERP TLS + relay handshake all succeed, node shows online, and packets flow (rx/tx increasing). All commits build on ESP-IDF v5.4 / esp32s3.

With every new option off / streaming_map_fetch=false, behavior against Tailscale's SaaS is unchanged.

kentaro added 2 commits July 21, 2026 23:49
Headscale/Ionscale deployments commonly expose the control plane behind
an HTTPS-only listener on port 443, which MicroLink's plain-TCP port-80
coordination transport cannot reach. This adds three Kconfig options to
make custom control planes usable at build time:

- CONFIG_ML_CTRL_HOST: coordination server hostname (build-time
  counterpart of the NVS web-UI override; useful for pre-provisioned
  firmware and when the config server is disabled)
- CONFIG_ML_CTRL_TLS: wrap the ts2021 Noise handshake in TLS on port
  443 (new src/ml_coord_tls.c, following the same mbedTLS custom-BIO
  pattern as the DERP connection). The server certificate is not
  verified; the control plane is authenticated by the pinned Noise key,
  the same trust model as the plain port-80 transport. Disabled by
  default — behavior is unchanged unless enabled.
- CONFIG_ML_CTRL_NOISE_PUBKEY_HEX: pin a custom control plane Noise
  public key (from https://<host>/key?v=88), replacing the built-in
  Tailscale key.

Tested against a Headscale-based control plane (443/TLS only): TLS
handshake, Noise handshake and registration path build on ESP-IDF
v5.4.4 / esp32s3.
Allow supplying the custom control plane hostname and Noise public key
at runtime through the config struct, in addition to the build-time
Kconfig options. This enables shipping a single pre-built firmware
where the control plane (and its /key-fetched Noise key) is provisioned
on-device instead of baked in. Priority: config struct > Kconfig >
built-in Tailscale values; the NVS web-UI host override still wins.

The README already documented a ctrl_host config field — this makes it
real, and extends the same idea to the Noise key.
@kentaro kentaro changed the title Add build-time custom control plane config with TLS transport Support custom (non-Tailscale) control planes end-to-end: TLS control, streaming map-poll, DERP Jul 22, 2026
kentaro added 2 commits July 22, 2026 14:51
…false

Some headscale-based control planes (e.g. Lolipop Zero Trust Link) do not
respond at all to the initial Stream=false MapRequest — the client sees 60
seconds of zero bytes, an empty MapResponse, and falls into a reconnect
loop, never obtaining peers or the DERPMap.

The native Tailscale client instead starts a single Stream=true streaming
poll whose first message already carries the full netmap (peers + DERPMap).
This adds a microlink_config_t.streaming_map_fetch flag (default false =
unchanged behavior). When true, COORD_FETCH_PEERS starts the long-poll with
OmitPeers=false, reads the initial netmap to completion, parses Node/Peers/
DERPMap, and only then connects DERP. Self-node and DERPMap parsing are
factored into shared helpers so both the Stream=false and Stream=true paths
populate them.
Three fixes needed to reach a custom control plane's DERP relay, found
while bringing up an ESP32 node on a headscale-based deployment:

1. Region fallback: when the server's HomeDERP region is not present in
   the DERPMap (custom control planes advertise their own region IDs, not
   Tailscale's region 9), fall back to the first available region in the
   DERPMap instead of the hardcoded Tailscale default host — otherwise the
   client dials derp9e.tailscale.com and fails.

2. Robust TLS setup: check the return value of every mbedTLS setup call
   and abort before mbedtls_ssl_handshake on failure. Previously
   mbedtls_ssl_setup failing (typically -0x7F00 ALLOC_FAILED under internal
   RAM pressure) was ignored; it NULLs ssl->conf, so handshake then returned
   MBEDTLS_ERR_SSL_BAD_INPUT_DATA and the real cause (OOM) was masked as
   "Bad input parameters". Also add derp_tls_abort() to free the mbedTLS
   context (and ~20KB TLS buffers) on every failure path — the old code only
   closed the socket, leaking on each retry and worsening the OOM over time.
   (With CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC the fix on the app side is
   CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y to allocate TLS buffers from PSRAM.)

3. Pin DERP TLS to 1.2: on ESP-IDF mbedTLS the TLS 1.3 path cannot parse an
   ECDSA server certificate's signature-algorithm OID ("X509 - Signature
   algorithm (oid) is unsupported"), even with authmode=VERIFY_NONE, because
   TLS 1.3 can't skip certificate processing. DERP relays accept TLS 1.2, so
   cap the max version at 1.2 to avoid the 1.3 certificate path.
@kentaro
kentaro force-pushed the feature/custom-control-plane-tls branch from 1e3e8ef to 125b529 Compare July 22, 2026 05:51
kentaro added 6 commits July 22, 2026 16:11
wireguardif_process_data_message called ip_input() directly from the
WireGuard rx task. When the inner packet is TCP, this ran tcp_input ->
tcp_output on the rx task concurrently with app-side socket operations on
the TCPIP thread, corrupting the PCB's unacked segment list and crashing
in tcp_output (LoadProhibited, NULL useg) whenever a TCP response arrived
over the tunnel.

Deliver through netif->input instead (hosts set this to tcpip_input) so
all TCP processing is serialized on the TCPIP thread. Fall back to
ip_input when netif->input is unset. Free the pbuf if input() fails.
The device is an ESP32 running ESP-IDF, not Linux. Admin dashboards
display this value as the node's OS, so report it honestly.
The control plane reports auth problems in-band (RegisterResponse Error /
MachineAuthorized=false), but do_register never checked them: a rejected
registration looked like success and the client spun in the reconnect
loop with no way for the host app to tell the user. Headless devices have
no UI to notice this — hosts need a hook to show their equivalent of a
re-login prompt.

- do_register returns ML_REG_AUTH_FAILED on in-band rejection
- The coordinator raises ML_STATE_AUTH_FAILED through the state callback
  and jumps to maximum backoff (the loop keeps running in case the key is
  re-enabled server-side)
- The key-expired-without-auth-key path raises the same state
The reconnect loop set ml->state = ML_STATE_RECONNECTING without invoking
the state callback, unlike ML_STATE_CONNECTED and ML_STATE_AUTH_FAILED.
Host apps relying on the callback for status display kept showing
"connected" through a network outage (found in a live Wi-Fi outage test:
the device UI stayed on the connected status while ml_coord was cycling
through reconnect attempts).

Fire the callback once on the transition into RECONNECTING; the loop
re-enters this state on every backoff cycle, so guard on the previous
state to avoid repeated callbacks.
The previous commit fired the RECONNECTING callback whenever the loop
entered the reconnect state, which also fired right after a registration
rejection had raised AUTH_FAILED — clobbering the host's "re-login"
display with a generic "reconnecting" one (observed live: dashboard
device deletion -> "authkey expired" rejection -> screen stuck on
"reconnecting" instead of the re-auth prompt).

AUTH_FAILED means human action is required; keep it sticky while the
loop retries at maximum backoff. A successful reconnect still moves the
state forward via the CONNECTED callback.
The codebase's comments are English; keep the fork consistent (and
upstreamable via PR CamM2325#22).
kentaro added a commit to pepabo/microlink that referenced this pull request Jul 28, 2026
The codebase's comments are English; keep the fork consistent (and
upstreamable via PR CamM2325#22).
kentaro added 2 commits July 29, 2026 16:10
wireguard_tai64n_now() built the TAI64N timestamp from esp_timer_get_time(),
which is microseconds since boot, not wall-clock time.

A peer stores the greatest timestamp it has seen from us and rejects any
handshake initiation whose timestamp is not greater (replay protection,
WireGuard spec 5.1). With uptime, the counter restarts near zero on every
reboot, so the peer answers with "handshake replay" and drops the initiation.
A device that has been up for hours therefore cannot reconnect after a reboot
until its uptime passes the previous session's — in practice, never.

Observed on an ESP32-S3 talking to a userspace peer:

  wg: ConsumeMessageInitiation: handshake replay @ 1970-01-01 09:04:41
  wg: Received invalid initiation message from 8f9b9e9a...

DISCO probing succeeded the whole time, so the failure looked like a routing
problem rather than a rejected handshake.

Use gettimeofday() instead. The system clock is expected to be set before
connecting; the ztl component already syncs it over SNTP for this reason.

Also drop the periodic "[TAI64N] uptime=..." printf, which was debug
scaffolding and prints to the console every five seconds.
wireguard_tai64n_now() was the only user of esp_timer in this file.
@kentaro
kentaro force-pushed the feature/custom-control-plane-tls branch from f6b454e to a8d769e Compare July 29, 2026 16:34
fudio101 pushed a commit to fugo101/microlink that referenced this pull request Aug 18, 2026
Extends the DERP TLS-leak fix already on this branch with the rest of
what upstream shipped as one verified commit (125b529):

- DERP region fallback when HomeDERP isn't in the DERPMap (falls back
  to the first usable non-avoid region instead of a dead-end connect
  to the default host)
- Pin DERP TLS to 1.2 — some relays' Let's Encrypt ECDSA cert trips
  the ESP-IDF mbedTLS TLS 1.3 signature-algorithm OID path even under
  VERIFY_NONE
- Check every mbedTLS setup call's return value (config_defaults,
  ssl_setup, set_hostname) and abort via the existing fail_tls path
  instead of proceeding into handshake with a half-initialized config
- Rework the DERP BIO to a plain blocking f_recv bounded by
  SO_RCVTIMEO, dropping mbedtls_ssl_conf_read_timeout entirely —
  avoids a BAD_INPUT_DATA failure the recv_timeout scheme hit on some
  relays' TLS 1.3 path

Plus the other generic (non-headscale) fixes from the same upstream
PR:

- Hostinfo "OS" field corrected to "esp32" (was "linux")
- New ML_STATE_AUTH_FAILED state: do_register() now actually fails
  registration on a control-plane Error/MachineAuthorized rejection
  instead of silently "succeeding" into a broken state (Node absent,
  confusing downstream "node not found"); the expired-key-with-no-
  auth_key path raises the same state
- state_cb now fires on the RECONNECTING transition (it never did
  before) and doesn't get clobbered by it while AUTH_FAILED is active

Adapted from cplewes/microlink@38602ab0/@b25b1eee (already on this
branch) and CamM2325#22 commits 125b529, fcdc8d9,
ae3d438, 8367c1e, 9ef10bb — not a literal cherry-pick, base has
diverged too far, and our mbedTLS 4.x/PSA migration already dropped
the entropy/ctr_drbg fields upstream's version still frees.

Deliberately excluded (see UPSTREAM_PRS.md): the 3 headscale/custom-
control-plane feature commits from CamM2325#22 (this fork
targets Tailscale's own SaaS control plane), and the netif->input RX
fix (5c8d60c), already absorbed via CamM2325#20.

Refs #14, #43 (FORK_PRS.md dj-oyu bundle's TAI64N item superseded),
CamM2325#22

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fudio101 added a commit to fugo101/microlink that referenced this pull request Aug 18, 2026
* fix(derp): free the mbedTLS context on failed DERP connect

ml_derp_connect()'s failure paths after the TLS phase begins only
closed the raw socket — they never freed the mbedtls_ssl_context /
mbedtls_ssl_config initialized just above. Each failed handshake
leaked ~3-8 KB of internal heap, and microlink_rebind() reconnects
DERP on every WiFi reconnect, so this compounds fast under
captive-portal/bad-network conditions until no handshake can even
allocate buffers.

Extract derp_free_tls_state() (ssl/ssl_conf free + socket close) and
route every post-init failure through a single `fail_tls` label via
goto, reusing the same helper from ml_derp_disconnect()'s existing
graceful-teardown path so the two call sites can't drift apart.

Adapted from cplewes/microlink@38602ab0 and cplewes/microlink@b25b1eee.
Not a literal cherry-pick: our ml_derp_conn_t already dropped the
entropy/ctr_drbg fields as part of the mbedTLS 4.x / TF-PSA-Crypto
migration (RNG is PSA-owned now), so derp_free_tls_state() frees only
ssl/ssl_conf.

Closes #14

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(derp,coord): absorb bugfix/hardening subset of upstream PR #22

Extends the DERP TLS-leak fix already on this branch with the rest of
what upstream shipped as one verified commit (125b529):

- DERP region fallback when HomeDERP isn't in the DERPMap (falls back
  to the first usable non-avoid region instead of a dead-end connect
  to the default host)
- Pin DERP TLS to 1.2 — some relays' Let's Encrypt ECDSA cert trips
  the ESP-IDF mbedTLS TLS 1.3 signature-algorithm OID path even under
  VERIFY_NONE
- Check every mbedTLS setup call's return value (config_defaults,
  ssl_setup, set_hostname) and abort via the existing fail_tls path
  instead of proceeding into handshake with a half-initialized config
- Rework the DERP BIO to a plain blocking f_recv bounded by
  SO_RCVTIMEO, dropping mbedtls_ssl_conf_read_timeout entirely —
  avoids a BAD_INPUT_DATA failure the recv_timeout scheme hit on some
  relays' TLS 1.3 path

Plus the other generic (non-headscale) fixes from the same upstream
PR:

- Hostinfo "OS" field corrected to "esp32" (was "linux")
- New ML_STATE_AUTH_FAILED state: do_register() now actually fails
  registration on a control-plane Error/MachineAuthorized rejection
  instead of silently "succeeding" into a broken state (Node absent,
  confusing downstream "node not found"); the expired-key-with-no-
  auth_key path raises the same state
- state_cb now fires on the RECONNECTING transition (it never did
  before) and doesn't get clobbered by it while AUTH_FAILED is active

Adapted from cplewes/microlink@38602ab0/@b25b1eee (already on this
branch) and CamM2325#22 commits 125b529, fcdc8d9,
ae3d438, 8367c1e, 9ef10bb — not a literal cherry-pick, base has
diverged too far, and our mbedTLS 4.x/PSA migration already dropped
the entropy/ctr_drbg fields upstream's version still frees.

Deliberately excluded (see UPSTREAM_PRS.md): the 3 headscale/custom-
control-plane feature commits from CamM2325#22 (this fork
targets Tailscale's own SaaS control plane), and the netif->input RX
fix (5c8d60c), already absorbed via CamM2325#20.

Refs #14, #43 (FORK_PRS.md dj-oyu bundle's TAI64N item superseded),
CamM2325#22

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Adrian.Nguyen-Qualgo <nguyen.ndt@qualgo.net>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant