mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 09:18:29 +00:00
fix(agent): allow disabling Tailscale netns (#618)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Next
|
# 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)).
|
- 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)).
|
- 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
|
# 0.7.0
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ const agentConfig = type({
|
|||||||
cache_ttl: "number.integer = 180000",
|
cache_ttl: "number.integer = 180000",
|
||||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||||
|
tailscale_netns: "boolean = true",
|
||||||
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
|
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
|
||||||
cache_path: type("unknown").narrow(deprecatedField()).optional(),
|
cache_path: type("unknown").narrow(deprecatedField()).optional(),
|
||||||
});
|
});
|
||||||
@@ -212,6 +213,7 @@ const partialAgentConfig = type({
|
|||||||
cache_ttl: "number.integer?",
|
cache_ttl: "number.integer?",
|
||||||
executable_path: "string?",
|
executable_path: "string?",
|
||||||
work_dir: "string?",
|
work_dir: "string?",
|
||||||
|
tailscale_netns: "boolean?",
|
||||||
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
|
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
|
||||||
cache_path: 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 cacheTtl = agentConfig.cache_ttl ?? 180_000;
|
||||||
const executablePath = agentConfig.executable_path;
|
const executablePath = agentConfig.executable_path;
|
||||||
const workDir = agentConfig.work_dir;
|
const workDir = agentConfig.work_dir;
|
||||||
|
const tailscaleNetNS = agentConfig.tailscale_netns;
|
||||||
|
|
||||||
const state: SyncState = {
|
const state: SyncState = {
|
||||||
syncedAt: null,
|
syncedAt: null,
|
||||||
@@ -125,6 +126,7 @@ export async function createAgentManager(
|
|||||||
HEADPLANE_AGENT_TS_SERVER: headscaleUrl,
|
HEADPLANE_AGENT_TS_SERVER: headscaleUrl,
|
||||||
HEADPLANE_AGENT_HOSTNAME: hostName,
|
HEADPLANE_AGENT_HOSTNAME: hostName,
|
||||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
|
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
|
||||||
|
HEADPLANE_AGENT_TS_NETNS: tailscaleNetNS ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authKey) {
|
if (authKey) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/tale/headplane/internal/config"
|
"github.com/tale/headplane/internal/config"
|
||||||
"github.com/tale/headplane/internal/tsnet"
|
"github.com/tale/headplane/internal/tsnet"
|
||||||
"github.com/tale/headplane/internal/util"
|
"github.com/tale/headplane/internal/util"
|
||||||
|
"tailscale.com/net/netns"
|
||||||
)
|
)
|
||||||
|
|
||||||
type output struct {
|
type output struct {
|
||||||
@@ -41,6 +42,11 @@ func main() {
|
|||||||
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
|
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)
|
agent := tsnet.NewAgent(cfg)
|
||||||
defer agent.Shutdown()
|
defer agent.Shutdown()
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,14 @@ integration:
|
|||||||
# If using Docker, it is best to leave this as the default.
|
# If using Docker, it is best to leave this as the default.
|
||||||
# work_dir: "/var/lib/headplane/agent"
|
# 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
|
# 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
|
# 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.
|
# 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`
|
_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
|
## settings.integration.agent.work_dir
|
||||||
|
|
||||||
_Description:_ Do not change this unless you are running a custom deployment.
|
_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)
|
[example configuration](https://github.com/tale/headplane/blob/main/config.example.yaml)
|
||||||
for details.
|
for details.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| ----------------------------------- | ------------------------------------------------------------------------------- |
|
| ----------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| **`integration.agent.enabled`** | Set to `true` to enable the agent. |
|
| **`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.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.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.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.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
|
## 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,
|
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.
|
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
|
## Interactive approval
|
||||||
|
|
||||||
Under normal circumstances, the agent connects headlessly using the auto-generated
|
Under normal circumstances, the agent connects headlessly using the auto-generated
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Config struct {
|
|||||||
Hostname string
|
Hostname string
|
||||||
TSControlURL string
|
TSControlURL string
|
||||||
TSAuthKey string
|
TSAuthKey string
|
||||||
|
TSNetNS bool
|
||||||
WorkDir string
|
WorkDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const (
|
|||||||
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
||||||
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
||||||
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
||||||
|
TSNetNSEnv = "HEADPLANE_AGENT_TS_NETNS"
|
||||||
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
|
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ func Load() (*Config, error) {
|
|||||||
Hostname: os.Getenv(HostnameEnv),
|
Hostname: os.Getenv(HostnameEnv),
|
||||||
TSControlURL: os.Getenv(TSControlURLEnv),
|
TSControlURL: os.Getenv(TSControlURLEnv),
|
||||||
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
||||||
|
TSNetNS: os.Getenv(TSNetNSEnv) != "false",
|
||||||
WorkDir: os.Getenv(WorkDirEnv),
|
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" {};
|
package = mkPackageOption pkgs "headplane-agent" {};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -225,4 +225,32 @@ describe("Configuration YAML file loading", () => {
|
|||||||
const config = await loadConfig(filePath);
|
const config = await loadConfig(filePath);
|
||||||
expect(config.oidc).toBeUndefined();
|
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