fix(agent): allow disabling Tailscale netns (#618)

This commit is contained in:
QEDeD
2026-08-25 07:04:15 +02:00
committed by GitHub
parent 9375e18b55
commit a5b6ecf28e
11 changed files with 157 additions and 7 deletions
+1
View File
@@ -1,6 +1,7 @@
# Next
- Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)).
- Added `integration.agent.tailscale_netns`, an agent-only opt-out from Tailscale's routing-loop socket handling for deployments where its fallback pins the agent's Headscale connection to the wrong interface. Existing behavior remains enabled by default.
- Fixed creating pre-auth keys with an expiry of 1000 days or more. The number input submitted its locale-formatted value (`365,000`, `365 000`, `365.000`), which either failed with a 500 or silently created a key with a truncated expiry. The raw value is now submitted and the server rejects malformed expiries with a 400 (closes [#596](https://github.com/tale/headplane/issues/596)).
# 0.7.0
+2
View File
@@ -202,6 +202,7 @@ const agentConfig = type({
cache_ttl: "number.integer = 180000",
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
tailscale_netns: "boolean = true",
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
cache_path: type("unknown").narrow(deprecatedField()).optional(),
});
@@ -212,6 +213,7 @@ const partialAgentConfig = type({
cache_ttl: "number.integer?",
executable_path: "string?",
work_dir: "string?",
tailscale_netns: "boolean?",
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
cache_path: type("unknown").narrow(deprecatedField()).optional(),
});
+2
View File
@@ -94,6 +94,7 @@ export async function createAgentManager(
const cacheTtl = agentConfig.cache_ttl ?? 180_000;
const executablePath = agentConfig.executable_path;
const workDir = agentConfig.work_dir;
const tailscaleNetNS = agentConfig.tailscale_netns;
const state: SyncState = {
syncedAt: null,
@@ -125,6 +126,7 @@ export async function createAgentManager(
HEADPLANE_AGENT_TS_SERVER: headscaleUrl,
HEADPLANE_AGENT_HOSTNAME: hostName,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
HEADPLANE_AGENT_TS_NETNS: tailscaleNetNS ? "true" : "false",
};
if (authKey) {
+6
View File
@@ -11,6 +11,7 @@ import (
"github.com/tale/headplane/internal/config"
"github.com/tale/headplane/internal/tsnet"
"github.com/tale/headplane/internal/util"
"tailscale.com/net/netns"
)
type output struct {
@@ -41,6 +42,11 @@ func main() {
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
}
if !cfg.TSNetNS {
log.Info("Tailscale network namespace handling disabled")
netns.SetEnabled(false)
}
agent := tsnet.NewAgent(cfg)
defer agent.Shutdown()
+8
View File
@@ -155,6 +155,14 @@ integration:
# If using Docker, it is best to leave this as the default.
# work_dir: "/var/lib/headplane/agent"
# Whether hp_agent uses Tailscale's socket handling to keep
# Tailscale-originated traffic from being routed back through
# Tailscale-managed routes. Keep enabled unless its fallback pins the
# agent's Headscale connection to the wrong interface. Set to false only
# after verifying that ordinary OS routing in the container's network
# namespace reaches Headscale correctly.
# tailscale_netns: true
# Only one of these should be enabled at a time or you will get errors
# This does not include the agent integration (above), which can be enabled
# at the same time as any of these and is recommended for the best experience.
+10
View File
@@ -185,6 +185,16 @@ _Type:_ package
_Default:_ `pkgs.headplane-agent`
## settings.integration.agent.tailscale_netns
_Description:_ Use Tailscale's socket-level routing-loop handling in the dedicated Headplane agent process.
Keep enabled unless its fallback pins the agent's Headscale connection to the wrong interface.
Set to false only after verifying that ordinary OS routing in the container's network namespace reaches Headscale correctly.
_Type:_ boolean
_Default:_ `true`
## settings.integration.agent.work_dir
_Description:_ Do not change this unless you are running a custom deployment.
+43 -7
View File
@@ -31,13 +31,14 @@ please refer to the
[example configuration](https://github.com/tale/headplane/blob/main/config.example.yaml)
for details.
| Field | Description |
| ----------------------------------- | ------------------------------------------------------------------------------- |
| **`integration.agent.enabled`** | Set to `true` to enable the agent. |
| `integration.agent.host_name` | _Optional_. Headscale user name for the agent (default: `headplane-agent`). |
| `integration.agent.cache_ttl` | _Optional_. How often to sync in milliseconds (default: `180000` / 3 minutes). |
| `integration.agent.work_dir` | _Optional_. Working directory for the agent's tailnet state. |
| `integration.agent.executable_path` | _Optional_. Path to the agent binary (default: `/usr/libexec/headplane/agent`). |
| Field | Description |
| ----------------------------------- | --------------------------------------------------------------------------------- |
| **`integration.agent.enabled`** | Set to `true` to enable the agent. |
| `integration.agent.host_name` | _Optional_. Headscale user name for the agent (default: `headplane-agent`). |
| `integration.agent.cache_ttl` | _Optional_. How often to sync in milliseconds (default: `180000` / 3 minutes). |
| `integration.agent.work_dir` | _Optional_. Working directory for the agent's tailnet state. |
| `integration.agent.executable_path` | _Optional_. Path to the agent binary (default: `/usr/libexec/headplane/agent`). |
| `integration.agent.tailscale_netns` | _Optional_. Use Tailscale's socket-level routing-loop handling (default: `true`). |
## Native Mode Configuration
@@ -62,6 +63,41 @@ the agent retain its Tailnet identity across Headplane restarts instead of
registering as a new host each time. If the agent's state is lost or unusable,
Headplane falls back to the pre-auth key and registers a new agent node.
## Tailscale socket routing handling
By default, the agent uses Tailscale's socket handling to keep
Tailscale-originated traffic from being routed back through Tailscale-managed
routes. Tailscale attempts to apply its bypass mark to its outbound sockets so
its routing and policy machinery can identify that traffic.
With all capabilities dropped, `SO_MARK` returns `EPERM`. This does not break
the container's routing; it causes Tailscale to fall back to
`SO_BINDTODEVICE(DefaultRouteInterface())`. In a multi-network container, that
fallback can pin the agent's Headscale connection to the default interface even
though the container's Linux routing table has a correct Headscale-specific
route through another interface. In this topology, a successful `SO_MARK` is
not what selects the Headscale-facing interface; ordinary destination routing
already makes the correct selection.
After verifying that ordinary OS routing in the container's network namespace
reaches Headscale correctly, the agent can rely on that routing instead:
```yaml
integration:
agent:
enabled: true
tailscale_netns: false
```
Setting this to `false` disables Tailscale's mark-or-bind socket handling only
inside the dedicated `hp_agent` process. `hp_agent` and the main Headplane
process continue to share the container's Linux network namespace. The main
process's networking behavior, container capabilities, Docker networks,
interfaces, routing table, and default gateway remain unchanged. Leave this
setting enabled unless the fallback is known to select the wrong interface;
bare-metal and Tailscale-routed deployments may rely on its loop-avoidance
behavior.
## Interactive approval
Under normal circumstances, the agent connects headlessly using the auto-generated
+3
View File
@@ -8,6 +8,7 @@ type Config struct {
Hostname string
TSControlURL string
TSAuthKey string
TSNetNS bool
WorkDir string
}
@@ -16,6 +17,7 @@ const (
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
TSNetNSEnv = "HEADPLANE_AGENT_TS_NETNS"
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
)
@@ -26,6 +28,7 @@ func Load() (*Config, error) {
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
TSNetNS: os.Getenv(TSNetNSEnv) != "false",
WorkDir: os.Getenv(WorkDirEnv),
}
+44
View File
@@ -0,0 +1,44 @@
package config
import (
"net/http"
"net/http/httptest"
"testing"
)
func loadTestConfig(t *testing.T, tailscaleNetNS string) *Config {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
t.Setenv(DebugEnv, "false")
t.Setenv(HostnameEnv, "headplane-agent")
t.Setenv(TSControlURLEnv, server.URL)
t.Setenv(TSAuthKeyEnv, "test-auth-key")
t.Setenv(TSNetNSEnv, tailscaleNetNS)
t.Setenv(WorkDirEnv, t.TempDir())
cfg, err := Load()
if err != nil {
t.Fatalf("Load() returned an error: %v", err)
}
return cfg
}
func TestLoadTailscaleNetNSEnabledByDefault(t *testing.T) {
cfg := loadTestConfig(t, "")
if !cfg.TSNetNS {
t.Fatal("TSNetNS is false, want true")
}
}
func TestLoadTailscaleNetNSDisabled(t *testing.T) {
cfg := loadTestConfig(t, "false")
if cfg.TSNetNS {
t.Fatal("TSNetNS is true, want false")
}
}
+10
View File
@@ -313,6 +313,16 @@ in {
'';
};
tailscale_netns = mkOption {
type = types.bool;
default = true;
description = ''
Use Tailscale's socket-level routing-loop handling in the dedicated Headplane agent process.
Keep enabled unless its fallback pins the agent's Headscale connection to the wrong interface.
Set to false only after verifying that ordinary OS routing in the container's network namespace reaches Headscale correctly.
'';
};
package = mkPackageOption pkgs "headplane-agent" {};
};
};
+28
View File
@@ -225,4 +225,32 @@ describe("Configuration YAML file loading", () => {
const config = await loadConfig(filePath);
expect(config.oidc).toBeUndefined();
});
test("agent tailscale_netns defaults to true and can be disabled", async () => {
const defaultFilePath = "/config/agent-default-netns.yaml";
writeYaml(defaultFilePath, {
headscale: {
url: "http://localhost:8080",
api_key: "my-api-key",
},
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
integration: { agent: { enabled: true } },
});
const defaultConfig = await loadConfig(defaultFilePath);
expect(defaultConfig.integration?.agent?.tailscale_netns).toBe(true);
const disabledFilePath = "/config/agent-disabled-netns.yaml";
writeYaml(disabledFilePath, {
headscale: {
url: "http://localhost:8080",
api_key: "my-api-key",
},
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
integration: { agent: { enabled: true, tailscale_netns: false } },
});
const disabledConfig = await loadConfig(disabledFilePath);
expect(disabledConfig.integration?.agent?.tailscale_netns).toBe(false);
});
});