Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,26 @@ V2 uses fully dynamic DERP discovery — no manual region configuration needed.

MicroLink supports custom coordination servers like [Headscale](https://github.com/juanfont/headscale) and [Ionscale](https://github.com/jsiebens/ionscale). This allows you to run your own private Tailscale-compatible control plane.

**Configuration:** Set the control plane host via the HTTP config server web UI (Device Settings → Control Plane Host) or programmatically via `ctrl_host` in the config struct.
**Configuration:** Set the control plane host via the HTTP config server web UI (Device Settings → Control Plane Host) or programmatically via `ctrl_host` in the config struct. The server's Noise public key can likewise be supplied at runtime via `ctrl_noise_pubkey` (32 bytes) — fetch it from `https://<host>/key?v=88` (`"publicKey":"mkey:<hex>"`) during provisioning, or pin it at build time (below).

**Build-time configuration:** For pre-provisioned firmware, or when the HTTP config server is disabled, the control plane can also be set at build time:

```ini
# sdkconfig.defaults
CONFIG_ML_CTRL_HOST="headscale.example.com"

# Control planes that only expose an HTTPS listener (e.g. Headscale behind
# a reverse proxy / load balancer on port 443) cannot accept MicroLink's
# default plain-TCP port-80 transport. This wraps the ts2021 Noise
# handshake in TLS on port 443 instead. The server certificate is not
# verified; the control plane is authenticated by the pinned Noise public
# key below (same trust model as the plain port-80 transport).
CONFIG_ML_CTRL_TLS=y

# The control plane's Noise public key, from
# https://<host>/key?v=88 → "publicKey":"mkey:<hex>" (64-char hex)
CONFIG_ML_CTRL_NOISE_PUBKEY_HEX="..."
```

**Server key:** MicroLink automatically fetches the server's Noise public key from the `/key` endpoint via HTTPS. No manual key configuration required.

Expand Down
1 change: 1 addition & 0 deletions components/microlink/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
set(SRCS
"src/microlink.c"
"src/ml_coord.c"
"src/ml_coord_tls.c"
"src/ml_derp.c"
"src/ml_net_io.c"
"src/ml_wg_mgr.c"
Expand Down
31 changes: 31 additions & 0 deletions components/microlink/Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,37 @@ menu "MicroLink V2 Configuration"

endmenu

menu "Control Plane"

config ML_CTRL_HOST
string "Control plane hostname"
default "controlplane.tailscale.com"
help
Coordination server hostname. Set to a Headscale-compatible
host (e.g. Lolipop Zero Trust Link controller) to use a
custom control plane. NVS runtime override still wins.

config ML_CTRL_TLS
bool "Connect to control plane over TLS (port 443)"
default n
help
Wrap the ts2021 Noise handshake in TLS on port 443 instead
of plain TCP on port 80. Required for control planes that
only expose an HTTPS listener (e.g. Headscale behind an LB).
The server certificate is NOT verified; the control plane is
authenticated by the pinned Noise public key instead, same
trust model as Tailscale's plain port-80 transport.

config ML_CTRL_NOISE_PUBKEY_HEX
string "Control plane Noise public key (hex)"
default ""
help
64-char hex of the control plane's Noise (machine) public key,
from https://<control-host>/key?v=88 → "publicKey":"mkey:<hex>".
Empty = use the built-in Tailscale key.

endmenu

menu "HTTP Config Server"

config ML_ENABLE_CONFIG_HTTPD
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

#include "wireguard-platform.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "lwip/sys.h"
#include <string.h>
#include <sys/time.h>

/* ============================================================================
* Time Functions
Expand All @@ -19,18 +19,19 @@ uint32_t wireguard_sys_now() {
}

void wireguard_tai64n_now(uint8_t *output) {
// TAI64N format: 8 bytes seconds + 4 bytes nanoseconds
// For simplicity, use Unix epoch time
uint64_t now_us = esp_timer_get_time();
uint64_t seconds = now_us / 1000000ULL;
uint32_t nanoseconds = (now_us % 1000000ULL) * 1000;

// Log raw uptime before TAI offset (only every ~5s to avoid spam)
static uint64_t last_log_s = 0;
if (seconds - last_log_s >= 5) {
printf("[TAI64N] uptime=%llu s, nano=%lu\n", (unsigned long long)seconds, (unsigned long)nanoseconds);
last_log_s = seconds;
}
// TAI64N format: 8 bytes seconds + 4 bytes nanoseconds.
//
// This must be wall-clock time, not uptime. A peer keeps 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, every reboot restarts the counter near zero, so the peer rejects
// us with "handshake replay" until our uptime passes the previous session's
// — a device that ran for hours cannot reconnect at all after a reboot.
// The system clock is expected to be set (e.g. by SNTP) before connecting.
struct timeval tv;
gettimeofday(&tv, NULL);
uint64_t seconds = (uint64_t)tv.tv_sec;
uint32_t nanoseconds = (uint32_t)tv.tv_usec * 1000;

// TAI64 starts at 1970-01-01 00:00:10 TAI (Unix epoch + 10 seconds)
// Add TAI offset: 2^62 + Unix time
Expand Down
17 changes: 14 additions & 3 deletions components/microlink/components/wireguard_lwip/src/wireguardif.c
Original file line number Diff line number Diff line change
Expand Up @@ -537,10 +537,21 @@ static void wireguardif_process_data_message(struct wireguard_device *device, st

// 5. If the plaintext packet has not been dropped, it is inserted into the receive queue of the wg0 interface.
if (dest_ok) {
// Send packet to be processed by LWIP
// Deliver through netif->input (the host sets this to
// tcpip_input) so TCP runs on the TCPIP thread, serialized
// with app-side socket operations. Calling ip_input()
// directly here processes TCP on the WireGuard rx task
// concurrently with app sockets and corrupts lwIP PCB state
// (the unacked segment list), which crashes in tcp_output.
WG_DEBUG("[WG_RX_IP] Passing %u bytes to IP layer\n", (unsigned)pbuf->tot_len);
ip_input(pbuf, device->netif);
// pbuf is owned by IP layer now
if (device->netif->input != NULL) {
if (device->netif->input(pbuf, device->netif) != ERR_OK) {
pbuf_free(pbuf);
}
} else {
ip_input(pbuf, device->netif);
}
// pbuf is owned by the IP/TCPIP layer now
pbuf = NULL;
} else {
WG_DEBUG("[WG_RX_IP] DROPPED: dest_ok=false\n");
Expand Down
25 changes: 25 additions & 0 deletions components/microlink/include/microlink.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ typedef struct {
uint32_t disco_heartbeat_ms; /* DISCO keepalive interval (default: 3000) */
uint32_t stun_interval_ms; /* STUN re-probe interval (default: 23000) */
uint32_t ctrl_watchdog_ms; /* Control plane watchdog timeout (default: 120000) */

/* Custom control plane (Headscale / Ionscale). NULL = use the
* CONFIG_ML_CTRL_HOST / CONFIG_ML_CTRL_NOISE_PUBKEY_HEX build-time
* values, falling back to Tailscale's. The NVS web-UI override, if
* present, still wins over both. */
const char *ctrl_host; /* Coordination server hostname */
const uint8_t *ctrl_noise_pubkey; /* Server Noise public key (32 bytes),
e.g. fetched from https://<host>/key?v=88
at provisioning time */

/* Some headscale-based controllers never respond to the initial
* Stream=false MapRequest (e.g. ZTL: 60s of zero bytes -> Empty
* MapResponse -> reconnect loop). The official Tailscale client uses a
* single Stream=true streaming poll from the start, and the first stream
* message carries the full netmap (Peers + DERPMap).
* Setting this to true switches to that scheme (start with Stream=true /
* OmitPeers=false and take Peers + DERPMap from the first MapResponse).
* false (default) keeps the existing Stream=false one-shot fetch. */
bool streaming_map_fetch;
} microlink_config_t;

/* Peer info (read-only snapshot) */
Expand All @@ -65,6 +84,12 @@ typedef enum {
ML_STATE_CONNECTED,
ML_STATE_RECONNECTING,
ML_STATE_ERROR,
/* Registration was rejected by the control plane (expired/revoked auth
* key, or the node awaits authorization). The stack keeps retrying at
* maximum backoff, but recovery normally needs a new auth key — hosts
* should surface this to the user as their equivalent of a "re-login"
* prompt (headless devices have no UI to notice it otherwise). */
ML_STATE_AUTH_FAILED,
} microlink_state_t;

/* Callback types */
Expand Down
38 changes: 37 additions & 1 deletion components/microlink/include/microlink_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ extern "C" {
#define ML_DERP_PORT 443

/* Tailscale control plane */
#ifdef CONFIG_ML_CTRL_HOST
#define ML_CTRL_HOST CONFIG_ML_CTRL_HOST
#else
#define ML_CTRL_HOST "controlplane.tailscale.com"
#endif
#define ML_CTRL_PORT 443
#define ML_CTRL_PROTOCOL_VER 131

Expand Down Expand Up @@ -330,6 +334,19 @@ typedef struct {
uint64_t last_recv_ms; /* For keepalive watchdog */
} ml_derp_conn_t;

/* ============================================================================
* Control Plane TLS State (CONFIG_ML_CTRL_TLS)
* ========================================================================== */

typedef struct {
mbedtls_ssl_context ssl; /* Owned exclusively by coord task */
mbedtls_ssl_config ssl_conf;
mbedtls_entropy_context entropy;
mbedtls_ctr_drbg_context ctr_drbg;
int sockfd; /* Copy of coord_sock for the BIO callbacks */
bool active; /* TLS session established */
} ml_coord_tls_t;

/* ============================================================================
* Main Context
* ========================================================================== */
Expand Down Expand Up @@ -379,6 +396,9 @@ struct microlink_s {

/* Coordination socket (owned exclusively by coord task) */
int coord_sock;
#ifdef CONFIG_ML_CTRL_TLS
ml_coord_tls_t coord_tls;
#endif
uint32_t h2_next_stream_id; /* Next H2 stream ID for endpoint updates (odd, starts at 7) */

/* WireGuard netif (owned exclusively by wg_mgr task) */
Expand Down Expand Up @@ -442,9 +462,16 @@ struct microlink_s {
char nvs_device_name[48];

/* Control plane host override (empty = use ML_CTRL_HOST default).
* Set from NVS at boot for Headscale/Ionscale/custom coordinators. */
* Set from config struct or NVS at boot for Headscale/Ionscale/custom
* coordinators. */
char ctrl_host[64];

/* Control plane Noise public key override (from config struct;
* ctrl_noise_pubkey_set false = use CONFIG_ML_CTRL_NOISE_PUBKEY_HEX
* or the built-in Tailscale key) */
uint8_t ctrl_noise_pubkey[32];
bool ctrl_noise_pubkey_set;

/* Debug flags (bitmask from NVS, checked at runtime for verbose logging) */
uint8_t debug_flags; /* bit 0: DISCO, bit 1: WG, bit 2: DERP, bit 3: coord */

Expand Down Expand Up @@ -488,6 +515,15 @@ bool ml_stun_parse_response(const uint8_t *data, size_t len,
bool ml_stun_parse_response_ipv6(const uint8_t *data, size_t len,
uint8_t *out_ip6, uint16_t *out_port);

/* ml_coord_tls.c (CONFIG_ML_CTRL_TLS) */
#ifdef CONFIG_ML_CTRL_TLS
int ml_coord_tls_handshake(microlink_t *ml, const char *hostname);
int ml_coord_tls_send(microlink_t *ml, const uint8_t *data, size_t len);
int ml_coord_tls_recv(microlink_t *ml, uint8_t *buf, size_t len);
size_t ml_coord_tls_pending(microlink_t *ml);
void ml_coord_tls_free(microlink_t *ml);
#endif

/* ml_noise.c */
void ml_noise_init(ml_noise_state_t *state,
const uint8_t *local_private, const uint8_t *local_public,
Expand Down
10 changes: 10 additions & 0 deletions components/microlink/src/microlink.c
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ microlink_t *microlink_init(const microlink_config_t *config) {
if (ml->config.max_peers > ML_MAX_PEERS) ml->config.max_peers = ML_MAX_PEERS;
ml->config.enable_derp = true; /* Always need DERP for relay */

/* Custom control plane from config struct (NVS override below still wins) */
if (config->ctrl_host && config->ctrl_host[0]) {
strncpy(ml->ctrl_host, config->ctrl_host, sizeof(ml->ctrl_host) - 1);
ESP_LOGI(TAG, "Control plane from config: %s", ml->ctrl_host);
}
if (config->ctrl_noise_pubkey) {
memcpy(ml->ctrl_noise_pubkey, config->ctrl_noise_pubkey, 32);
ml->ctrl_noise_pubkey_set = true;
}

ml->state = ML_STATE_IDLE;
ml->coord_sock = -1;
ml->disco_sock4 = -1;
Expand Down
Loading