mirror of
https://github.com/tale/headplane.git
synced 2026-08-26 04:16:49 +00:00
fix(agent): allow disabling Tailscale netns (#618)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" {};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user