mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
feat(remote): add support for custom tailscale control servers (#1312)
* feat(tailscale): add custom control URL configuration & handling w/ tests - Introduced TailscaleControlURL in the Config struct to allow configuration of the Tailscale control server. - Added RPC handlers for getting and setting the Tailscale control URL. - Updated TailscaleStatus to include controlURL, ensuring it reflects the configured or default value. - Enhanced parsing and normalization of the control URL to enforce valid formats. - Updated TailscaleCard component to manage and display the control server URL, allowing users to save changes. * docs: update README to include optional Tailscale networking feature Added a new section highlighting the built-in Tailscale status and control-server configuration, including support for custom Headscale-compatible endpoints. * docs: Extend DEVELOPMENT.md with Tailscale control server details * fix(tailscale): enhance error handling in rpcSetTailscaleControlURL - Updated the rpcSetTailscaleControlURL function to revert the TailscaleControlURL to its previous value if saving or applying the new URL fails. - Added a new test to ensure that the configuration is not saved when the apply command fails, verifying that the previous URL remains intact. - Adjusted existing tests to validate the order of operations during the URL setting process. Related to Review: https://github.com/jetkvm/kvm/pull/1312#pullrequestreview-3984856379 * refactor(tailscale): simplify control URL application logic and enhance error handling - Renamed the test function to better reflect its purpose and updated the test cases to ensure correct command execution. - Removed fallback logic for applying the Tailscale control URL, streamlining the error handling to return a clear error message when the "set" command fails. - Added a new test to verify behavior when the "set" command fails, ensuring proper error reporting. Related to Review https://github.com/jetkvm/kvm/pull/1312#pullrequestreview-3984856379 * refactor(tailscale): update control server configuration in UI & documentation - Updated the TailscaleCard component to allow users to select between default and custom control server URLs. - Improved state management for control server URL input based on the selected mode. - Revised DEVELOPMENT.md to clarify control server application logic and error handling. - Removed outdated example JSON-RPC payloads for clarity. Related to Review: https://github.com/jetkvm/kvm/pull/1312#discussion_r2980284611 * refactor(ui): use built-in components and i18n for TailscaleCard - Replace raw <select> with SelectMenuBasic component - Use SettingsItem with new SM size for control server setting - Use NestedSettingsGroup for indented custom URL input - Add tailscale_* i18n keys to all 14 locale files with translations - Add size prop (SM/MD) to SettingsItem for compact contexts --------- Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
This commit is contained in:
committed by
GitHub
parent
e7e1a289df
commit
519e391595
@@ -144,6 +144,7 @@ tail -f /userdata/jetkvm/last.log
|
||||
|
||||
- `web.go` - Add new API endpoints here
|
||||
- `config.go` - Add new settings here
|
||||
- `tailscale.go` - Tailscale status and control-server logic
|
||||
- `ui/src/routes/` - Add new pages here
|
||||
- `ui/src/components/` - Add new UI components here
|
||||
|
||||
@@ -250,6 +251,21 @@ curl -X POST http://<IP>/auth/password-local \
|
||||
-d '{"password": "test123"}'
|
||||
```
|
||||
|
||||
### Tailscale control server testing
|
||||
|
||||
JetKVM exposes Tailscale control-server configuration through JSON-RPC so self-hosted control planes (for example Headscale) can be used.
|
||||
|
||||
- `getTailscaleStatus` returns current state and effective `controlURL`
|
||||
- `getTailscaleControlURL` returns the effective control server URL
|
||||
- `setTailscaleControlURL` updates and persists the URL (empty value resets to default)
|
||||
|
||||
Notes:
|
||||
|
||||
- URLs must be `http://` or `https://` and include a host.
|
||||
- Query strings, fragments, user info, and non-root paths are rejected.
|
||||
- Control server changes are applied with `tailscale set --login-server=...`.
|
||||
- When apply fails, the previous configured URL is restored and not persisted.
|
||||
|
||||
|
||||
### End to End Testing
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) s
|
||||
|
||||
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
|
||||
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
|
||||
- **Optional Tailscale Networking** - Built-in Tailscale status and control-server configuration, including custom [Headscale](https://headscale.net/)-compatible endpoints.
|
||||
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -89,6 +89,7 @@ type Config struct {
|
||||
UpdateAPIURL string `json:"update_api_url"`
|
||||
CloudAppURL string `json:"cloud_app_url"`
|
||||
CloudToken string `json:"cloud_token"`
|
||||
TailscaleControlURL string `json:"tailscale_control_url,omitempty"`
|
||||
GoogleIdentity string `json:"google_identity"`
|
||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||
|
||||
@@ -1214,6 +1214,8 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
|
||||
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
|
||||
"getTailscaleStatus": {Func: rpcGetTailscaleStatus},
|
||||
"getTailscaleControlURL": {Func: rpcGetTailscaleControlURL},
|
||||
"setTailscaleControlURL": {Func: rpcSetTailscaleControlURL, Params: []string{"controlURL"}},
|
||||
"getMqttSettings": {Func: rpcGetMqttSettings},
|
||||
"setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}},
|
||||
"getMqttStatus": {Func: rpcGetMqttStatus},
|
||||
|
||||
+125
-15
@@ -3,7 +3,9 @@ package kvm
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,12 +13,15 @@ import (
|
||||
|
||||
const tailscaleCommandTimeout = 10 * time.Second
|
||||
|
||||
const tailscaleDefaultControlURL = "https://controlplane.tailscale.com"
|
||||
|
||||
// TailscaleStatus represents the current state of Tailscale on the device.
|
||||
type TailscaleStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
Running bool `json:"running"`
|
||||
BackendState string `json:"backendState,omitempty"`
|
||||
AuthURL string `json:"authURL,omitempty"`
|
||||
ControlURL string `json:"controlURL,omitempty"`
|
||||
Self *TailscalePeer `json:"self,omitempty"`
|
||||
Health []string `json:"health,omitempty"`
|
||||
}
|
||||
@@ -52,41 +57,108 @@ func isTailscaleInstalled() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// execTailscaleStatus runs `tailscale status --json` and returns the raw output.
|
||||
// This is a package-level var to allow test substitution.
|
||||
var execTailscaleStatus = func() ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tailscaleCommandTimeout)
|
||||
defer cancel()
|
||||
// These package-level vars allow deterministic unit tests.
|
||||
var (
|
||||
checkTailscaleInstalled = isTailscaleInstalled
|
||||
saveTailscaleConfig = SaveConfig
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tailscaleCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, err := exec.CommandContext(ctx, "tailscale", "status", "--json").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tailscale status: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
output, err := exec.CommandContext(ctx, "tailscale", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
cmd := "tailscale " + strings.Join(args, " ")
|
||||
return nil, fmt.Errorf("%s: %w: %s", cmd, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
)
|
||||
|
||||
// execTailscaleStatus runs `tailscale status --json` and returns the raw output.
|
||||
func execTailscaleStatus() ([]byte, error) {
|
||||
return execTailscaleCommand("status", "--json")
|
||||
}
|
||||
|
||||
func normalizeTailscaleControlURL(controlURL string) (string, error) {
|
||||
trimmed := strings.TrimSpace(controlURL)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return output, nil
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid control URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return "", errors.New("control URL must start with http:// or https://")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", errors.New("control URL must include a host")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", errors.New("control URL must not include user info")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("control URL must not include query or fragment")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", errors.New("control URL path is not supported")
|
||||
}
|
||||
|
||||
parsed.Path = ""
|
||||
parsed.RawPath = ""
|
||||
|
||||
return strings.TrimSuffix(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func effectiveTailscaleControlURL(controlURL string) string {
|
||||
if controlURL == "" {
|
||||
return tailscaleDefaultControlURL
|
||||
}
|
||||
return controlURL
|
||||
}
|
||||
|
||||
func applyTailscaleControlURL(controlURL string) error {
|
||||
effectiveURL := effectiveTailscaleControlURL(controlURL)
|
||||
loginServerFlag := "--login-server=" + effectiveURL
|
||||
|
||||
if _, err := execTailscaleCommand("set", loginServerFlag); err != nil {
|
||||
return fmt.Errorf("failed to apply login server (%s): %w", effectiveURL, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTailscaleStatus queries the Tailscale daemon for current status.
|
||||
// Returns a TailscaleStatus with Installed=false when the binary is not found.
|
||||
func getTailscaleStatus() (*TailscaleStatus, error) {
|
||||
if !isTailscaleInstalled() {
|
||||
return &TailscaleStatus{Installed: false}, nil
|
||||
ensureConfigLoaded()
|
||||
|
||||
controlURL := config.TailscaleControlURL
|
||||
if !checkTailscaleInstalled() {
|
||||
return &TailscaleStatus{
|
||||
Installed: false,
|
||||
ControlURL: effectiveTailscaleControlURL(controlURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
output, err := execTailscaleStatus()
|
||||
if err != nil {
|
||||
tailscaleLogger.Warn().Err(err).Msg("failed to get tailscale status")
|
||||
return &TailscaleStatus{
|
||||
Installed: true,
|
||||
Running: false,
|
||||
Installed: true,
|
||||
Running: false,
|
||||
ControlURL: effectiveTailscaleControlURL(controlURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return parseTailscaleStatus(output)
|
||||
return parseTailscaleStatus(output, controlURL)
|
||||
}
|
||||
|
||||
// parseTailscaleStatus parses the JSON output from `tailscale status --json`.
|
||||
func parseTailscaleStatus(data []byte) (*TailscaleStatus, error) {
|
||||
func parseTailscaleStatus(data []byte, controlURL string) (*TailscaleStatus, error) {
|
||||
var raw tailscaleRawStatus
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tailscale status: %w", err)
|
||||
@@ -97,6 +169,7 @@ func parseTailscaleStatus(data []byte) (*TailscaleStatus, error) {
|
||||
Running: raw.BackendState == "Running",
|
||||
BackendState: raw.BackendState,
|
||||
AuthURL: raw.AuthURL,
|
||||
ControlURL: effectiveTailscaleControlURL(controlURL),
|
||||
Health: raw.Health,
|
||||
}
|
||||
|
||||
@@ -116,3 +189,40 @@ func parseTailscaleStatus(data []byte) (*TailscaleStatus, error) {
|
||||
func rpcGetTailscaleStatus() (*TailscaleStatus, error) {
|
||||
return getTailscaleStatus()
|
||||
}
|
||||
|
||||
func rpcGetTailscaleControlURL() (string, error) {
|
||||
ensureConfigLoaded()
|
||||
return effectiveTailscaleControlURL(config.TailscaleControlURL), nil
|
||||
}
|
||||
|
||||
func rpcSetTailscaleControlURL(controlURL string) error {
|
||||
ensureConfigLoaded()
|
||||
|
||||
normalizedURL, err := normalizeTailscaleControlURL(controlURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
previousURL := config.TailscaleControlURL
|
||||
config.TailscaleControlURL = normalizedURL
|
||||
|
||||
if !checkTailscaleInstalled() {
|
||||
if err := saveTailscaleConfig(); err != nil {
|
||||
config.TailscaleControlURL = previousURL
|
||||
return fmt.Errorf("failed to save tailscale control URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := applyTailscaleControlURL(normalizedURL); err != nil {
|
||||
config.TailscaleControlURL = previousURL
|
||||
return err
|
||||
}
|
||||
|
||||
if err := saveTailscaleConfig(); err != nil {
|
||||
config.TailscaleControlURL = previousURL
|
||||
return fmt.Errorf("failed to save tailscale control URL: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+221
-38
@@ -5,16 +5,19 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseTailscaleStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
controlURL string
|
||||
wantErr bool
|
||||
wantRunning bool
|
||||
wantState string
|
||||
wantAuthURL string
|
||||
wantControl string
|
||||
wantHostName string
|
||||
wantIPs []string
|
||||
}{
|
||||
@@ -31,8 +34,10 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
},
|
||||
"Health": []
|
||||
}`,
|
||||
controlURL: "https://headscale.example.com",
|
||||
wantRunning: true,
|
||||
wantState: "Running",
|
||||
wantControl: "https://headscale.example.com",
|
||||
wantHostName: "cortex-kvm",
|
||||
wantIPs: []string{"100.80.194.50", "fd7a:115c:a1e0::1"},
|
||||
},
|
||||
@@ -44,9 +49,11 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
"Self": null,
|
||||
"Health": []
|
||||
}`,
|
||||
controlURL: "",
|
||||
wantRunning: false,
|
||||
wantState: "NeedsLogin",
|
||||
wantAuthURL: "https://login.tailscale.com/a/abc123",
|
||||
wantControl: tailscaleDefaultControlURL,
|
||||
},
|
||||
{
|
||||
name: "stopped",
|
||||
@@ -55,8 +62,10 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
"Self": null,
|
||||
"Health": []
|
||||
}`,
|
||||
controlURL: "https://headscale.example.com/",
|
||||
wantRunning: false,
|
||||
wantState: "Stopped",
|
||||
wantControl: "https://headscale.example.com/",
|
||||
},
|
||||
{
|
||||
name: "starting",
|
||||
@@ -65,19 +74,24 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
"Self": null,
|
||||
"Health": ["not yet connected"]
|
||||
}`,
|
||||
controlURL: "",
|
||||
wantRunning: false,
|
||||
wantState: "Starting",
|
||||
wantControl: tailscaleDefaultControlURL,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
input: `{invalid`,
|
||||
wantErr: true,
|
||||
name: "invalid json",
|
||||
input: `{invalid`,
|
||||
controlURL: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty json",
|
||||
input: `{}`,
|
||||
controlURL: "https://headscale.example.com",
|
||||
wantRunning: false,
|
||||
wantState: "",
|
||||
wantControl: "https://headscale.example.com",
|
||||
},
|
||||
{
|
||||
name: "running without IPs",
|
||||
@@ -91,8 +105,10 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
"OS": "linux"
|
||||
}
|
||||
}`,
|
||||
controlURL: "",
|
||||
wantRunning: true,
|
||||
wantState: "Running",
|
||||
wantControl: tailscaleDefaultControlURL,
|
||||
wantHostName: "test-node",
|
||||
wantIPs: []string{},
|
||||
},
|
||||
@@ -100,7 +116,7 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
status, err := parseTailscaleStatus([]byte(tt.input))
|
||||
status, err := parseTailscaleStatus([]byte(tt.input), tt.controlURL)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
@@ -111,6 +127,7 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
assert.Equal(t, tt.wantRunning, status.Running)
|
||||
assert.Equal(t, tt.wantState, status.BackendState)
|
||||
assert.Equal(t, tt.wantAuthURL, status.AuthURL)
|
||||
assert.Equal(t, tt.wantControl, status.ControlURL)
|
||||
|
||||
if tt.wantHostName != "" {
|
||||
assert.NotNil(t, status.Self)
|
||||
@@ -125,48 +142,95 @@ func TestParseTailscaleStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTailscaleStatus_NotInstalled(t *testing.T) {
|
||||
// Save and restore the original exec function
|
||||
origExec := execTailscaleStatus
|
||||
defer func() { execTailscaleStatus = origExec }()
|
||||
func TestNormalizeTailscaleControlURL(t *testing.T) {
|
||||
t.Run("empty means default", func(t *testing.T) {
|
||||
got, err := normalizeTailscaleControlURL("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", got)
|
||||
})
|
||||
|
||||
// The function should never be called when tailscale is not in PATH.
|
||||
// We can't easily mock exec.LookPath, but we can verify parseTailscaleStatus
|
||||
// handles all the edge cases above. This test verifies the exec mock path.
|
||||
execTailscaleStatus = func() ([]byte, error) {
|
||||
return nil, fmt.Errorf("tailscale not running")
|
||||
t.Run("valid URL trimmed", func(t *testing.T) {
|
||||
got, err := normalizeTailscaleControlURL(" https://headscale.example.com/ ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://headscale.example.com", got)
|
||||
})
|
||||
|
||||
t.Run("rejects path", func(t *testing.T) {
|
||||
_, err := normalizeTailscaleControlURL("https://headscale.example.com/api")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path")
|
||||
})
|
||||
|
||||
t.Run("rejects invalid scheme", func(t *testing.T) {
|
||||
_, err := normalizeTailscaleControlURL("ftp://headscale.example.com")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "http:// or https://")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTailscaleStatus_NotInstalled(t *testing.T) {
|
||||
origCheck := checkTailscaleInstalled
|
||||
origExec := execTailscaleCommand
|
||||
origConfig := config
|
||||
defer func() {
|
||||
checkTailscaleInstalled = origCheck
|
||||
execTailscaleCommand = origExec
|
||||
config = origConfig
|
||||
}()
|
||||
|
||||
config = &Config{TailscaleControlURL: "https://headscale.example.com"}
|
||||
checkTailscaleInstalled = func() bool { return false }
|
||||
execTailscaleCommand = func(_ ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("should not be called")
|
||||
}
|
||||
|
||||
status, err := getTailscaleStatus()
|
||||
// When tailscale is installed but daemon is down, we get installed=true, running=false
|
||||
// When not installed, LookPath fails and we get installed=false
|
||||
// Since we can't mock LookPath easily, just verify the error path through exec
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, status)
|
||||
// Status depends on whether tailscale binary exists on the test machine
|
||||
// The important thing is it never returns an error
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status)
|
||||
assert.False(t, status.Installed)
|
||||
assert.False(t, status.Running)
|
||||
assert.Equal(t, "https://headscale.example.com", status.ControlURL)
|
||||
}
|
||||
|
||||
func TestGetTailscaleStatus_ExecFailure(t *testing.T) {
|
||||
origExec := execTailscaleStatus
|
||||
defer func() { execTailscaleStatus = origExec }()
|
||||
origCheck := checkTailscaleInstalled
|
||||
origExec := execTailscaleCommand
|
||||
origConfig := config
|
||||
defer func() {
|
||||
checkTailscaleInstalled = origCheck
|
||||
execTailscaleCommand = origExec
|
||||
config = origConfig
|
||||
}()
|
||||
|
||||
execTailscaleStatus = func() ([]byte, error) {
|
||||
config = &Config{}
|
||||
checkTailscaleInstalled = func() bool { return true }
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
require.Equal(t, []string{"status", "--json"}, args)
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
}
|
||||
|
||||
status, err := getTailscaleStatus()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, status)
|
||||
assert.True(t, status.Installed || !status.Installed) // depends on test env
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status)
|
||||
assert.True(t, status.Installed)
|
||||
assert.False(t, status.Running)
|
||||
assert.Equal(t, tailscaleDefaultControlURL, status.ControlURL)
|
||||
}
|
||||
|
||||
func TestGetTailscaleStatus_ValidJSON(t *testing.T) {
|
||||
origExec := execTailscaleStatus
|
||||
defer func() { execTailscaleStatus = origExec }()
|
||||
origCheck := checkTailscaleInstalled
|
||||
origExec := execTailscaleCommand
|
||||
origConfig := config
|
||||
defer func() {
|
||||
checkTailscaleInstalled = origCheck
|
||||
execTailscaleCommand = origExec
|
||||
config = origConfig
|
||||
}()
|
||||
|
||||
execTailscaleStatus = func() ([]byte, error) {
|
||||
config = &Config{TailscaleControlURL: "https://headscale.example.com"}
|
||||
checkTailscaleInstalled = func() bool { return true }
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
require.Equal(t, []string{"status", "--json"}, args)
|
||||
return []byte(`{
|
||||
"BackendState": "Running",
|
||||
"Self": {
|
||||
@@ -181,13 +245,132 @@ func TestGetTailscaleStatus_ValidJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
status, err := getTailscaleStatus()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, status)
|
||||
// If tailscale binary doesn't exist on test machine, we get installed=false
|
||||
// and the exec mock is never called. Both paths are valid.
|
||||
if status.Installed {
|
||||
assert.True(t, status.Running)
|
||||
assert.Equal(t, "test-kvm", status.Self.HostName)
|
||||
assert.Equal(t, []string{"100.64.0.1"}, status.Self.TailscaleIPs)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status)
|
||||
assert.True(t, status.Installed)
|
||||
assert.True(t, status.Running)
|
||||
assert.Equal(t, "https://headscale.example.com", status.ControlURL)
|
||||
require.NotNil(t, status.Self)
|
||||
assert.Equal(t, "test-kvm", status.Self.HostName)
|
||||
assert.Equal(t, []string{"100.64.0.1"}, status.Self.TailscaleIPs)
|
||||
}
|
||||
|
||||
func TestApplyTailscaleControlURL_SetOnly(t *testing.T) {
|
||||
origExec := execTailscaleCommand
|
||||
defer func() { execTailscaleCommand = origExec }()
|
||||
|
||||
var commands [][]string
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
commands = append(commands, append([]string{}, args...))
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
|
||||
err := applyTailscaleControlURL("https://headscale.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, commands, 1)
|
||||
assert.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, commands[0])
|
||||
}
|
||||
|
||||
func TestApplyTailscaleControlURL_SetFailureReturnedWithoutFallback(t *testing.T) {
|
||||
origExec := execTailscaleCommand
|
||||
defer func() { execTailscaleCommand = origExec }()
|
||||
|
||||
var commands [][]string
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
commands = append(commands, append([]string{}, args...))
|
||||
return nil, fmt.Errorf("unknown command")
|
||||
}
|
||||
|
||||
err := applyTailscaleControlURL("https://headscale.example.com")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to apply login server")
|
||||
require.Len(t, commands, 1)
|
||||
assert.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, commands[0])
|
||||
}
|
||||
|
||||
func TestRPCSetTailscaleControlURL_SaveAndApply(t *testing.T) {
|
||||
origCheck := checkTailscaleInstalled
|
||||
origExec := execTailscaleCommand
|
||||
origSave := saveTailscaleConfig
|
||||
origConfig := config
|
||||
defer func() {
|
||||
checkTailscaleInstalled = origCheck
|
||||
execTailscaleCommand = origExec
|
||||
saveTailscaleConfig = origSave
|
||||
config = origConfig
|
||||
}()
|
||||
|
||||
config = &Config{}
|
||||
checkTailscaleInstalled = func() bool { return true }
|
||||
var callOrder []string
|
||||
saveTailscaleConfig = func() error {
|
||||
callOrder = append(callOrder, "save")
|
||||
return nil
|
||||
}
|
||||
|
||||
var commands [][]string
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
callOrder = append(callOrder, "apply")
|
||||
commands = append(commands, append([]string{}, args...))
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
|
||||
err := rpcSetTailscaleControlURL("https://headscale.example.com/")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"apply", "save"}, callOrder)
|
||||
assert.Equal(t, "https://headscale.example.com", config.TailscaleControlURL)
|
||||
require.Len(t, commands, 1)
|
||||
assert.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, commands[0])
|
||||
}
|
||||
|
||||
func TestRPCSetTailscaleControlURL_ApplyFailureDoesNotSaveOrPersistConfig(t *testing.T) {
|
||||
origCheck := checkTailscaleInstalled
|
||||
origExec := execTailscaleCommand
|
||||
origSave := saveTailscaleConfig
|
||||
origConfig := config
|
||||
defer func() {
|
||||
checkTailscaleInstalled = origCheck
|
||||
execTailscaleCommand = origExec
|
||||
saveTailscaleConfig = origSave
|
||||
config = origConfig
|
||||
}()
|
||||
|
||||
config = &Config{TailscaleControlURL: "https://previous.example.com"}
|
||||
checkTailscaleInstalled = func() bool { return true }
|
||||
saveTailscaleConfig = func() error {
|
||||
t.Fatal("save should not be called when apply fails")
|
||||
return nil
|
||||
}
|
||||
execTailscaleCommand = func(args ...string) ([]byte, error) {
|
||||
require.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, args)
|
||||
return nil, fmt.Errorf("apply failed")
|
||||
}
|
||||
|
||||
err := rpcSetTailscaleControlURL("https://headscale.example.com")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "https://previous.example.com", config.TailscaleControlURL)
|
||||
}
|
||||
|
||||
func TestRPCSetTailscaleControlURL_NotInstalledSkipsApply(t *testing.T) {
|
||||
origCheck := checkTailscaleInstalled
|
||||
origExec := execTailscaleCommand
|
||||
origSave := saveTailscaleConfig
|
||||
origConfig := config
|
||||
defer func() {
|
||||
checkTailscaleInstalled = origCheck
|
||||
execTailscaleCommand = origExec
|
||||
saveTailscaleConfig = origSave
|
||||
config = origConfig
|
||||
}()
|
||||
|
||||
config = &Config{}
|
||||
checkTailscaleInstalled = func() bool { return false }
|
||||
saveTailscaleConfig = func() error { return nil }
|
||||
execTailscaleCommand = func(_ ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("should not be called")
|
||||
}
|
||||
|
||||
err := rpcSetTailscaleControlURL("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", config.TailscaleControlURL)
|
||||
}
|
||||
|
||||
@@ -812,6 +812,28 @@
|
||||
"something_went_wrong": "Aeth rhywbeth o'i le. Rhowch gynnig arall arni yn nes ymlaen neu cysylltwch â chymorth",
|
||||
"step_counter_step": "Cam {step}",
|
||||
"subnet_mask": "Mwgwd Is-rwydwaith",
|
||||
"tailscale_auth_description": "Mae Tailscale angen dilysu. Agorwch y ddolen isod i fewngofnodi.",
|
||||
"tailscale_connected": "Wedi cysylltu",
|
||||
"tailscale_control_server_custom": "Personol",
|
||||
"tailscale_control_server_custom_url_label": "URL Gweinydd Rheoli Personol",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Rhagosodiad",
|
||||
"tailscale_control_server_description": "Ffurfweddwch bwynt terfyn plân rheoli Tailscale",
|
||||
"tailscale_control_server_title": "Gweinydd Rheoli",
|
||||
"tailscale_control_server_update_failed": "Methwyd â diweddaru gweinydd rheoli Tailscale: {error}",
|
||||
"tailscale_control_server_update_success": "Gweinydd rheoli Tailscale wedi'i ddiweddaru",
|
||||
"tailscale_dns_name": "Enw DNS",
|
||||
"tailscale_hostname": "Enw Gwesteiwr",
|
||||
"tailscale_installed_not_running": "Mae Tailscale wedi'i osod ond nid yw'n rhedeg.",
|
||||
"tailscale_installed_not_running_state": " Cyflwr: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Angen Mewngofnodi",
|
||||
"tailscale_refresh": "Adnewyddu",
|
||||
"tailscale_save": "Cadw",
|
||||
"tailscale_saving": "Yn cadw…",
|
||||
"tailscale_stopped": "Wedi stopio",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "diwrnodau",
|
||||
"time_division_hours": "oriau",
|
||||
"time_division_minutes": "munudau",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Tilføj din offentlige SSH-nøgle for at aktivere sikker fjernadgang til enheden",
|
||||
"advanced_ssh_access_title": "SSH-adgang",
|
||||
"advanced_ssh_default_user": "Standard SSH-brugeren er",
|
||||
"advanced_ssh_key_required_warning": "En offentlig nøgle er påkrævet for SSH-adgang. Uden en vil du ikke kunne oprette forbindelse.",
|
||||
"advanced_ssh_public_key_label": "Offentlig SSH-nøgle",
|
||||
"advanced_ssh_public_key_placeholder": "Indtast din offentlige SSH-nøgle",
|
||||
"advanced_success_download_diagnostics": "Diagnostik blev downloadet",
|
||||
"advanced_ssh_key_required_warning": "En offentlig nøgle er påkrævet for SSH-adgang. Uden en vil du ikke kunne oprette forbindelse.",
|
||||
"advanced_success_loopback_disabled": "Kun loopback-tilstand er deaktiveret. Genstart din enhed for at anvende den.",
|
||||
"advanced_success_loopback_enabled": "Kun loopback-tilstand aktiveret. Genstart din enhed for at anvende den.",
|
||||
"advanced_success_reset_config": "Konfigurationen er nulstillet til standard",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Noget gik galt. Prøv igen senere, eller kontakt support.",
|
||||
"step_counter_step": "Trin {step}",
|
||||
"subnet_mask": "Undernetmaske",
|
||||
"tailscale_auth_description": "Tailscale kræver godkendelse. Åbn linket nedenfor for at logge ind.",
|
||||
"tailscale_connected": "Forbundet",
|
||||
"tailscale_control_server_custom": "Tilpasset",
|
||||
"tailscale_control_server_custom_url_label": "Tilpasset kontrolserver-URL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Standard",
|
||||
"tailscale_control_server_description": "Konfigurer Tailscale-kontrolplanets slutpunkt",
|
||||
"tailscale_control_server_title": "Kontrolserver",
|
||||
"tailscale_control_server_update_failed": "Kunne ikke opdatere Tailscale-kontrolserveren: {error}",
|
||||
"tailscale_control_server_update_success": "Tailscale-kontrolserver opdateret",
|
||||
"tailscale_dns_name": "DNS-navn",
|
||||
"tailscale_hostname": "Værtsnavn",
|
||||
"tailscale_installed_not_running": "Tailscale er installeret, men kører ikke.",
|
||||
"tailscale_installed_not_running_state": " Tilstand: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Login påkrævet",
|
||||
"tailscale_refresh": "Opdater",
|
||||
"tailscale_save": "Gem",
|
||||
"tailscale_saving": "Gemmer…",
|
||||
"tailscale_stopped": "Stoppet",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "dage",
|
||||
"time_division_hours": "timer",
|
||||
"time_division_minutes": "minutter",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Fügen Sie Ihren öffentlichen SSH-Schlüssel hinzu, um einen sicheren Fernzugriff auf das Gerät zu ermöglichen",
|
||||
"advanced_ssh_access_title": "SSH-Zugriff",
|
||||
"advanced_ssh_default_user": "Der Standard-SSH-Benutzer ist",
|
||||
"advanced_ssh_key_required_warning": "Ein öffentlicher Schlüssel ist für den SSH-Zugang erforderlich. Ohne diesen können Sie keine Verbindung herstellen.",
|
||||
"advanced_ssh_public_key_label": "Öffentlicher SSH-Schlüssel",
|
||||
"advanced_ssh_public_key_placeholder": "Geben Sie Ihren öffentlichen SSH-Schlüssel ein",
|
||||
"advanced_success_download_diagnostics": "Diagnosedaten erfolgreich heruntergeladen",
|
||||
"advanced_ssh_key_required_warning": "Ein öffentlicher Schlüssel ist für den SSH-Zugang erforderlich. Ohne diesen können Sie keine Verbindung herstellen.",
|
||||
"advanced_success_loopback_disabled": "Nur-Loopback-Modus deaktiviert. Starten Sie Ihr Gerät neu, um die Funktion anzuwenden.",
|
||||
"advanced_success_loopback_enabled": "Nur-Loopback-Modus aktiviert. Starten Sie Ihr Gerät neu, um die Funktion anzuwenden.",
|
||||
"advanced_success_reset_config": "Konfiguration erfolgreich auf Standard zurückgesetzt",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es später noch einmal oder wenden Sie sich an den Support.",
|
||||
"step_counter_step": "Schritt {step}",
|
||||
"subnet_mask": "Subnetzmaske",
|
||||
"tailscale_auth_description": "Tailscale erfordert eine Authentifizierung. Öffnen Sie den untenstehenden Link, um sich anzumelden.",
|
||||
"tailscale_connected": "Verbunden",
|
||||
"tailscale_control_server_custom": "Benutzerdefiniert",
|
||||
"tailscale_control_server_custom_url_label": "Benutzerdefinierte Control-Server-URL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Standard",
|
||||
"tailscale_control_server_description": "Konfigurieren Sie den Tailscale-Control-Plane-Endpunkt",
|
||||
"tailscale_control_server_title": "Control-Server",
|
||||
"tailscale_control_server_update_failed": "Tailscale-Control-Server konnte nicht aktualisiert werden: {error}",
|
||||
"tailscale_control_server_update_success": "Tailscale-Control-Server aktualisiert",
|
||||
"tailscale_dns_name": "DNS-Name",
|
||||
"tailscale_hostname": "Hostname",
|
||||
"tailscale_installed_not_running": "Tailscale ist installiert, wird aber nicht ausgeführt.",
|
||||
"tailscale_installed_not_running_state": " Status: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Anmeldung erforderlich",
|
||||
"tailscale_refresh": "Aktualisieren",
|
||||
"tailscale_save": "Speichern",
|
||||
"tailscale_saving": "Wird gespeichert…",
|
||||
"tailscale_stopped": "Gestoppt",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "Tage",
|
||||
"time_division_hours": "Std.",
|
||||
"time_division_minutes": "Minuten",
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
"advanced_ssh_access_description": "Add your SSH public key to enable secure remote access to the device",
|
||||
"advanced_ssh_access_title": "SSH Access",
|
||||
"advanced_ssh_default_user": "The default SSH user is",
|
||||
"advanced_ssh_key_required_warning": "A public key is required for SSH access. Without one, you will not be able to connect.",
|
||||
"advanced_ssh_public_key_label": "SSH Public Key",
|
||||
"advanced_ssh_public_key_placeholder": "Enter your SSH public key",
|
||||
"advanced_ssh_key_required_warning": "A public key is required for SSH access. Without one, you will not be able to connect.",
|
||||
"advanced_success_download_diagnostics": "Diagnostics downloaded successfully",
|
||||
"advanced_success_loopback_disabled": "Loopback-only mode disabled. Restart your device to apply.",
|
||||
"advanced_success_loopback_enabled": "Loopback-only mode enabled. Restart your device to apply.",
|
||||
@@ -877,6 +877,28 @@
|
||||
"something_went_wrong": "Something went wrong. Please try again later or contact support",
|
||||
"step_counter_step": "Step {step}",
|
||||
"subnet_mask": "Subnet Mask",
|
||||
"tailscale_auth_description": "Tailscale requires authentication. Open the link below to log in.",
|
||||
"tailscale_connected": "Connected",
|
||||
"tailscale_control_server_custom": "Custom",
|
||||
"tailscale_control_server_custom_url_label": "Custom Control Server URL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Default",
|
||||
"tailscale_control_server_description": "Configure the Tailscale control plane endpoint",
|
||||
"tailscale_control_server_title": "Control Server",
|
||||
"tailscale_control_server_update_failed": "Failed to update Tailscale control server: {error}",
|
||||
"tailscale_control_server_update_success": "Tailscale control server updated",
|
||||
"tailscale_dns_name": "DNS Name",
|
||||
"tailscale_hostname": "Hostname",
|
||||
"tailscale_installed_not_running": "Tailscale is installed but not running.",
|
||||
"tailscale_installed_not_running_state": " State: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Needs Login",
|
||||
"tailscale_refresh": "Refresh",
|
||||
"tailscale_save": "Save",
|
||||
"tailscale_saving": "Saving...",
|
||||
"tailscale_stopped": "Stopped",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "days",
|
||||
"time_division_hours": "hours",
|
||||
"time_division_minutes": "minutes",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Agregue su clave pública SSH para habilitar el acceso remoto seguro al dispositivo",
|
||||
"advanced_ssh_access_title": "Acceso SSH",
|
||||
"advanced_ssh_default_user": "El usuario SSH predeterminado es",
|
||||
"advanced_ssh_key_required_warning": "Se requiere una clave pública para el acceso SSH. Sin ella, no podrá conectarse.",
|
||||
"advanced_ssh_public_key_label": "Clave pública SSH",
|
||||
"advanced_ssh_public_key_placeholder": "Ingrese su clave pública SSH",
|
||||
"advanced_success_download_diagnostics": "Diagnóstico descargado exitosamente",
|
||||
"advanced_ssh_key_required_warning": "Se requiere una clave pública para el acceso SSH. Sin ella, no podrá conectarse.",
|
||||
"advanced_success_loopback_disabled": "El modo de solo bucle invertido está deshabilitado. Reinicie el dispositivo para aplicarlo.",
|
||||
"advanced_success_loopback_enabled": "Modo de solo bucle invertido habilitado. Reinicie el dispositivo para aplicarlo.",
|
||||
"advanced_success_reset_config": "La configuración se restableció a los valores predeterminados correctamente",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Algo salió mal. Inténtalo de nuevo más tarde o contacta con el servicio de asistencia.",
|
||||
"step_counter_step": "Paso {step}",
|
||||
"subnet_mask": "Máscara de subred",
|
||||
"tailscale_auth_description": "Tailscale requiere autenticación. Abra el enlace de abajo para iniciar sesión.",
|
||||
"tailscale_connected": "Conectado",
|
||||
"tailscale_control_server_custom": "Personalizado",
|
||||
"tailscale_control_server_custom_url_label": "URL del servidor de control personalizado",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Predeterminado",
|
||||
"tailscale_control_server_description": "Configurar el punto de conexión del plano de control de Tailscale",
|
||||
"tailscale_control_server_title": "Servidor de control",
|
||||
"tailscale_control_server_update_failed": "No se pudo actualizar el servidor de control de Tailscale: {error}",
|
||||
"tailscale_control_server_update_success": "Servidor de control de Tailscale actualizado",
|
||||
"tailscale_dns_name": "Nombre DNS",
|
||||
"tailscale_hostname": "Nombre de host",
|
||||
"tailscale_installed_not_running": "Tailscale está instalado pero no se está ejecutando.",
|
||||
"tailscale_installed_not_running_state": " Estado: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Inicio de sesión necesario",
|
||||
"tailscale_refresh": "Actualizar",
|
||||
"tailscale_save": "Guardar",
|
||||
"tailscale_saving": "Guardando…",
|
||||
"tailscale_stopped": "Detenido",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "días",
|
||||
"time_division_hours": "horas",
|
||||
"time_division_minutes": "minutos",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Ajoutez votre clé publique SSH pour activer l'accès à distance sécurisé à l'appareil",
|
||||
"advanced_ssh_access_title": "Accès SSH",
|
||||
"advanced_ssh_default_user": "L'utilisateur SSH par défaut est",
|
||||
"advanced_ssh_key_required_warning": "Une clé publique est requise pour l'accès SSH. Sans celle-ci, vous ne pourrez pas vous connecter.",
|
||||
"advanced_ssh_public_key_label": "Clé publique SSH",
|
||||
"advanced_ssh_public_key_placeholder": "Entrez votre clé publique SSH",
|
||||
"advanced_success_download_diagnostics": "Diagnostics téléchargés avec succès",
|
||||
"advanced_ssh_key_required_warning": "Une clé publique est requise pour l'accès SSH. Sans celle-ci, vous ne pourrez pas vous connecter.",
|
||||
"advanced_success_loopback_disabled": "Mode de bouclage désactivé. Redémarrez votre appareil pour appliquer le mode de bouclage.",
|
||||
"advanced_success_loopback_enabled": "Mode de bouclage activé. Redémarrez votre appareil pour appliquer la fonction.",
|
||||
"advanced_success_reset_config": "La configuration par défaut a été réinitialisée avec succès",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Une erreur s'est produite. Veuillez réessayer ultérieurement ou contacter le support.",
|
||||
"step_counter_step": "Étape {step}",
|
||||
"subnet_mask": "Masque de sous-réseau",
|
||||
"tailscale_auth_description": "Tailscale nécessite une authentification. Ouvrez le lien ci-dessous pour vous connecter.",
|
||||
"tailscale_connected": "Connecté",
|
||||
"tailscale_control_server_custom": "Personnalisé",
|
||||
"tailscale_control_server_custom_url_label": "URL du serveur de contrôle personnalisé",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Par défaut",
|
||||
"tailscale_control_server_description": "Configurer le point de terminaison du plan de contrôle Tailscale",
|
||||
"tailscale_control_server_title": "Serveur de contrôle",
|
||||
"tailscale_control_server_update_failed": "Échec de la mise à jour du serveur de contrôle Tailscale : {error}",
|
||||
"tailscale_control_server_update_success": "Serveur de contrôle Tailscale mis à jour",
|
||||
"tailscale_dns_name": "Nom DNS",
|
||||
"tailscale_hostname": "Nom d'hôte",
|
||||
"tailscale_installed_not_running": "Tailscale est installé mais ne fonctionne pas.",
|
||||
"tailscale_installed_not_running_state": " État : {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Connexion requise",
|
||||
"tailscale_refresh": "Rafraîchir",
|
||||
"tailscale_save": "Enregistrer",
|
||||
"tailscale_saving": "Enregistrement…",
|
||||
"tailscale_stopped": "Arrêté",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "jours",
|
||||
"time_division_hours": "heures",
|
||||
"time_division_minutes": "minutes",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Aggiungi la tua chiave pubblica SSH per abilitare l'accesso remoto sicuro al dispositivo",
|
||||
"advanced_ssh_access_title": "Accesso SSH",
|
||||
"advanced_ssh_default_user": "L'utente SSH predefinito è",
|
||||
"advanced_ssh_key_required_warning": "Una chiave pubblica è necessaria per l'accesso SSH. Senza di essa, non sarà possibile connettersi.",
|
||||
"advanced_ssh_public_key_label": "Chiave pubblica SSH",
|
||||
"advanced_ssh_public_key_placeholder": "Inserisci la tua chiave pubblica SSH",
|
||||
"advanced_success_download_diagnostics": "Diagnostica scaricata correttamente",
|
||||
"advanced_ssh_key_required_warning": "Una chiave pubblica è necessaria per l'accesso SSH. Senza di essa, non sarà possibile connettersi.",
|
||||
"advanced_success_loopback_disabled": "Modalità loopback-only disattivata. Riavvia il dispositivo per applicare la modifica.",
|
||||
"advanced_success_loopback_enabled": "Modalità loopback abilitata. Riavvia il dispositivo per applicare la modifica.",
|
||||
"advanced_success_reset_config": "Configurazione ripristinata ai valori predefiniti con successo",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Qualcosa è andato storto. Riprova più tardi o contatta l'assistenza.",
|
||||
"step_counter_step": "Passaggio {step}",
|
||||
"subnet_mask": "Maschera di sottorete",
|
||||
"tailscale_auth_description": "Tailscale richiede l'autenticazione. Apri il link sottostante per accedere.",
|
||||
"tailscale_connected": "Collegato",
|
||||
"tailscale_control_server_custom": "Personalizzato",
|
||||
"tailscale_control_server_custom_url_label": "URL del server di controllo personalizzato",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Predefinito",
|
||||
"tailscale_control_server_description": "Configura l'endpoint del piano di controllo Tailscale",
|
||||
"tailscale_control_server_title": "Server di controllo",
|
||||
"tailscale_control_server_update_failed": "Impossibile aggiornare il server di controllo Tailscale: {error}",
|
||||
"tailscale_control_server_update_success": "Server di controllo Tailscale aggiornato",
|
||||
"tailscale_dns_name": "Nome DNS",
|
||||
"tailscale_hostname": "Nome host",
|
||||
"tailscale_installed_not_running": "Tailscale è installato ma non è in esecuzione.",
|
||||
"tailscale_installed_not_running_state": " Stato: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Accesso necessario",
|
||||
"tailscale_refresh": "Aggiorna",
|
||||
"tailscale_save": "Salva",
|
||||
"tailscale_saving": "Salvataggio in corso…",
|
||||
"tailscale_stopped": "Arrestato",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "giorni",
|
||||
"time_division_hours": "ore",
|
||||
"time_division_minutes": "minuti",
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
"advanced_ssh_access_description": "SSH公開鍵を追加して、デバイスへの安全なリモートアクセスを有効にします",
|
||||
"advanced_ssh_access_title": "SSHアクセス",
|
||||
"advanced_ssh_default_user": "デフォルトのSSHユーザーは",
|
||||
"advanced_ssh_key_required_warning": "SSHアクセスには公開鍵が必要です。公開鍵がないと接続できません。",
|
||||
"advanced_ssh_public_key_label": "SSH公開鍵",
|
||||
"advanced_ssh_public_key_placeholder": "SSH公開鍵を入力してください",
|
||||
"advanced_ssh_key_required_warning": "SSHアクセスには公開鍵が必要です。公開鍵がないと接続できません。",
|
||||
"advanced_success_download_diagnostics": "診断データが正常にダウンロードされました",
|
||||
"advanced_success_loopback_disabled": "ループバック専用モードが無効になりました。適用するにはデバイスを再起動してください。",
|
||||
"advanced_success_loopback_enabled": "ループバック専用モードが有効になりました。適用するにはデバイスを再起動してください。",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "問題が発生しました。後でもう一度試すか、サポートにお問い合わせください",
|
||||
"step_counter_step": "ステップ {step}",
|
||||
"subnet_mask": "サブネットマスク",
|
||||
"tailscale_auth_description": "Tailscale には認証が必要です。以下のリンクを開いてログインしてください。",
|
||||
"tailscale_connected": "接続済み",
|
||||
"tailscale_control_server_custom": "カスタム",
|
||||
"tailscale_control_server_custom_url_label": "カスタムコントロールサーバーURL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "デフォルト",
|
||||
"tailscale_control_server_description": "Tailscale コントロールプレーンのエンドポイントを設定します",
|
||||
"tailscale_control_server_title": "コントロールサーバー",
|
||||
"tailscale_control_server_update_failed": "Tailscale コントロールサーバーの更新に失敗しました: {error}",
|
||||
"tailscale_control_server_update_success": "Tailscale コントロールサーバーが更新されました",
|
||||
"tailscale_dns_name": "DNS名",
|
||||
"tailscale_hostname": "ホスト名",
|
||||
"tailscale_installed_not_running": "Tailscale はインストールされていますが、実行されていません。",
|
||||
"tailscale_installed_not_running_state": " 状態: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "ログインが必要",
|
||||
"tailscale_refresh": "更新",
|
||||
"tailscale_save": "保存",
|
||||
"tailscale_saving": "保存中…",
|
||||
"tailscale_stopped": "停止中",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "日",
|
||||
"time_division_hours": "時間",
|
||||
"time_division_minutes": "分",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Legg til din offentlige SSH-nøkkel for å aktivere sikker ekstern tilgang til enheten",
|
||||
"advanced_ssh_access_title": "SSH-tilgang",
|
||||
"advanced_ssh_default_user": "Standard SSH-brukeren er",
|
||||
"advanced_ssh_key_required_warning": "En offentlig nøkkel er påkrevd for SSH-tilgang. Uten en vil du ikke kunne koble til.",
|
||||
"advanced_ssh_public_key_label": "SSH offentlig nøkkel",
|
||||
"advanced_ssh_public_key_placeholder": "Skriv inn din offentlige SSH-nøkkel",
|
||||
"advanced_success_download_diagnostics": "Diagnostikken er lastet ned",
|
||||
"advanced_ssh_key_required_warning": "En offentlig nøkkel er påkrevd for SSH-tilgang. Uten en vil du ikke kunne koble til.",
|
||||
"advanced_success_loopback_disabled": "Kun tilbakekoblingsmodus deaktivert. Start enheten på nytt for å bruke den.",
|
||||
"advanced_success_loopback_enabled": "Kun tilbakekoblingsmodus aktivert. Start enheten på nytt for å bruke den.",
|
||||
"advanced_success_reset_config": "Konfigurasjonen ble tilbakestilt til standard",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Noe gikk galt. Prøv igjen senere eller kontakt kundestøtte.",
|
||||
"step_counter_step": "Trinn {step}",
|
||||
"subnet_mask": "Nettmaske",
|
||||
"tailscale_auth_description": "Tailscale krever autentisering. Åpne lenken nedenfor for å logge inn.",
|
||||
"tailscale_connected": "Tilkoblet",
|
||||
"tailscale_control_server_custom": "Tilpasset",
|
||||
"tailscale_control_server_custom_url_label": "Tilpasset kontrollserver-URL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Standard",
|
||||
"tailscale_control_server_description": "Konfigurer Tailscale-kontrollplanets endepunkt",
|
||||
"tailscale_control_server_title": "Kontrollserver",
|
||||
"tailscale_control_server_update_failed": "Kunne ikke oppdatere Tailscale-kontrollserveren: {error}",
|
||||
"tailscale_control_server_update_success": "Tailscale-kontrollserver oppdatert",
|
||||
"tailscale_dns_name": "DNS-navn",
|
||||
"tailscale_hostname": "Vertsnavn",
|
||||
"tailscale_installed_not_running": "Tailscale er installert, men kjører ikke.",
|
||||
"tailscale_installed_not_running_state": " Tilstand: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Innlogging nødvendig",
|
||||
"tailscale_refresh": "Oppdater",
|
||||
"tailscale_save": "Lagre",
|
||||
"tailscale_saving": "Lagrer…",
|
||||
"tailscale_stopped": "Stoppet",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "dager",
|
||||
"time_division_hours": "timer",
|
||||
"time_division_minutes": "minutter",
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
"advanced_ssh_access_description": "Adicione sua chave pública SSH para habilitar acesso remoto seguro ao dispositivo",
|
||||
"advanced_ssh_access_title": "Acesso SSH",
|
||||
"advanced_ssh_default_user": "O usuário SSH padrão é",
|
||||
"advanced_ssh_key_required_warning": "Uma chave pública é necessária para acesso SSH. Sem ela, você não conseguirá se conectar.",
|
||||
"advanced_ssh_public_key_label": "Chave Pública SSH",
|
||||
"advanced_ssh_public_key_placeholder": "Digite sua chave pública SSH",
|
||||
"advanced_ssh_key_required_warning": "Uma chave pública é necessária para acesso SSH. Sem ela, você não conseguirá se conectar.",
|
||||
"advanced_success_download_diagnostics": "Diagnósticos baixados com sucesso",
|
||||
"advanced_success_loopback_disabled": "Modo somente loopback desativado. Reinicie seu dispositivo para aplicar.",
|
||||
"advanced_success_loopback_enabled": "Modo somente loopback ativado. Reinicie seu dispositivo para aplicar.",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Algo deu errado. Tente novamente mais tarde ou entre em contato com o suporte",
|
||||
"step_counter_step": "Etapa {step}",
|
||||
"subnet_mask": "Máscara de Sub-rede",
|
||||
"tailscale_auth_description": "Tailscale requer autenticação. Abra o link abaixo para fazer login.",
|
||||
"tailscale_connected": "Conectado",
|
||||
"tailscale_control_server_custom": "Personalizado",
|
||||
"tailscale_control_server_custom_url_label": "URL do Servidor de Controle Personalizado",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Padrão",
|
||||
"tailscale_control_server_description": "Configurar o endpoint do plano de controle do Tailscale",
|
||||
"tailscale_control_server_title": "Servidor de Controle",
|
||||
"tailscale_control_server_update_failed": "Falha ao atualizar o servidor de controle do Tailscale: {error}",
|
||||
"tailscale_control_server_update_success": "Servidor de controle do Tailscale atualizado",
|
||||
"tailscale_dns_name": "Nome DNS",
|
||||
"tailscale_hostname": "Nome do Host",
|
||||
"tailscale_installed_not_running": "Tailscale está instalado mas não está em execução.",
|
||||
"tailscale_installed_not_running_state": " Estado: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Login Necessário",
|
||||
"tailscale_refresh": "Atualizar",
|
||||
"tailscale_save": "Salvar",
|
||||
"tailscale_saving": "Salvando…",
|
||||
"tailscale_stopped": "Parado",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "dias",
|
||||
"time_division_hours": "horas",
|
||||
"time_division_minutes": "minutos",
|
||||
|
||||
@@ -93,9 +93,9 @@
|
||||
"advanced_ssh_access_description": "Добавьте ваш публичный SSH-ключ для безопасного удалённого доступа к устройству",
|
||||
"advanced_ssh_access_title": "SSH-доступ",
|
||||
"advanced_ssh_default_user": "Пользователь по умолчанию для SSH:",
|
||||
"advanced_ssh_key_required_warning": "Для SSH-доступа необходим публичный ключ. Без него подключение будет невозможно.",
|
||||
"advanced_ssh_public_key_label": "Публичный SSH-ключ",
|
||||
"advanced_ssh_public_key_placeholder": "Введите ваш публичный SSH-ключ",
|
||||
"advanced_ssh_key_required_warning": "Для SSH-доступа необходим публичный ключ. Без него подключение будет невозможно.",
|
||||
"advanced_success_download_diagnostics": "Диагностика успешно скачана",
|
||||
"advanced_success_loopback_disabled": "Режим только loopback отключён. Перезапустите устройство для применения.",
|
||||
"advanced_success_loopback_enabled": "Режим только loopback включён. Перезапустите устройство для применения.",
|
||||
@@ -814,6 +814,28 @@
|
||||
"something_went_wrong": "Что-то пошло не так. Пожалуйста, попробуйте позже или свяжитесь с поддержкой",
|
||||
"step_counter_step": "Шаг {step}",
|
||||
"subnet_mask": "Маска подсети",
|
||||
"tailscale_auth_description": "Tailscale требует аутентификации. Откройте ссылку ниже, чтобы войти.",
|
||||
"tailscale_connected": "Подключено",
|
||||
"tailscale_control_server_custom": "Пользовательский",
|
||||
"tailscale_control_server_custom_url_label": "URL пользовательского сервера управления",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "По умолчанию",
|
||||
"tailscale_control_server_description": "Настроить конечную точку плоскости управления Tailscale",
|
||||
"tailscale_control_server_title": "Сервер управления",
|
||||
"tailscale_control_server_update_failed": "Не удалось обновить сервер управления Tailscale: {error}",
|
||||
"tailscale_control_server_update_success": "Сервер управления Tailscale обновлён",
|
||||
"tailscale_dns_name": "DNS-имя",
|
||||
"tailscale_hostname": "Имя хоста",
|
||||
"tailscale_installed_not_running": "Tailscale установлен, но не запущен.",
|
||||
"tailscale_installed_not_running_state": " Состояние: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Требуется вход",
|
||||
"tailscale_refresh": "Обновить",
|
||||
"tailscale_save": "Сохранить",
|
||||
"tailscale_saving": "Сохранение…",
|
||||
"tailscale_stopped": "Остановлен",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "дней",
|
||||
"time_division_hours": "часов",
|
||||
"time_division_minutes": "минут",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "Lägg till din offentliga SSH-nyckel för att aktivera säker fjärråtkomst till enheten",
|
||||
"advanced_ssh_access_title": "SSH-åtkomst",
|
||||
"advanced_ssh_default_user": "Standard SSH-användaren är",
|
||||
"advanced_ssh_key_required_warning": "En offentlig nyckel krävs för SSH-åtkomst. Utan en kommer du inte att kunna ansluta.",
|
||||
"advanced_ssh_public_key_label": "SSH-publik nyckel",
|
||||
"advanced_ssh_public_key_placeholder": "Ange din offentliga SSH-nyckel",
|
||||
"advanced_success_download_diagnostics": "Diagnostiken har laddats ner",
|
||||
"advanced_ssh_key_required_warning": "En offentlig nyckel krävs för SSH-åtkomst. Utan en kommer du inte att kunna ansluta.",
|
||||
"advanced_success_loopback_disabled": "Endast loopback-läge inaktiverat. Starta om enheten för att tillämpa det.",
|
||||
"advanced_success_loopback_enabled": "Endast loopback-läge aktiverat. Starta om enheten för att tillämpa.",
|
||||
"advanced_success_reset_config": "Konfigurationen återställdes till standardinställningarna",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "Något gick fel. Försök igen senare eller kontakta support.",
|
||||
"step_counter_step": "Steg {step}",
|
||||
"subnet_mask": "Subnätmask",
|
||||
"tailscale_auth_description": "Tailscale kräver autentisering. Öppna länken nedan för att logga in.",
|
||||
"tailscale_connected": "Ansluten",
|
||||
"tailscale_control_server_custom": "Anpassad",
|
||||
"tailscale_control_server_custom_url_label": "Anpassad kontrollserver-URL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "Standard",
|
||||
"tailscale_control_server_description": "Konfigurera Tailscale-kontrollplanets slutpunkt",
|
||||
"tailscale_control_server_title": "Kontrollserver",
|
||||
"tailscale_control_server_update_failed": "Misslyckades med att uppdatera Tailscale-kontrollservern: {error}",
|
||||
"tailscale_control_server_update_success": "Tailscale-kontrollserver uppdaterad",
|
||||
"tailscale_dns_name": "DNS-namn",
|
||||
"tailscale_hostname": "Värdnamn",
|
||||
"tailscale_installed_not_running": "Tailscale är installerat men körs inte.",
|
||||
"tailscale_installed_not_running_state": " Status: {state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "Inloggning krävs",
|
||||
"tailscale_refresh": "Uppdatera",
|
||||
"tailscale_save": "Spara",
|
||||
"tailscale_saving": "Sparar…",
|
||||
"tailscale_stopped": "Stoppad",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "dagar",
|
||||
"time_division_hours": "timmar",
|
||||
"time_division_minutes": "minuter",
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
"advanced_ssh_access_description": "新增您的 SSH 公鑰以啟用對裝置的安全遠端存取",
|
||||
"advanced_ssh_access_title": "SSH 存取",
|
||||
"advanced_ssh_default_user": "預設 SSH 使用者為",
|
||||
"advanced_ssh_key_required_warning": "SSH 存取需要公鑰。沒有公鑰將無法連線。",
|
||||
"advanced_ssh_public_key_label": "SSH 公鑰",
|
||||
"advanced_ssh_public_key_placeholder": "輸入您的 SSH 公鑰",
|
||||
"advanced_ssh_key_required_warning": "SSH 存取需要公鑰。沒有公鑰將無法連線。",
|
||||
"advanced_success_download_diagnostics": "診斷資料下載成功",
|
||||
"advanced_success_loopback_disabled": "單機回送模式已停用。請重新啟動您的裝置以套用。",
|
||||
"advanced_success_loopback_enabled": "單機回送模式已啟用。請重新啟動您的裝置以套用。",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "發生錯誤。請稍後再試或聯絡支援",
|
||||
"step_counter_step": "步驟 {step}",
|
||||
"subnet_mask": "子網路遮罩",
|
||||
"tailscale_auth_description": "Tailscale 需要進行身分驗證。請開啟下方連結登入。",
|
||||
"tailscale_connected": "已連線",
|
||||
"tailscale_control_server_custom": "自訂",
|
||||
"tailscale_control_server_custom_url_label": "自訂控制伺服器網址",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "預設",
|
||||
"tailscale_control_server_description": "設定 Tailscale 控制平面端點",
|
||||
"tailscale_control_server_title": "控制伺服器",
|
||||
"tailscale_control_server_update_failed": "更新 Tailscale 控制伺服器失敗:{error}",
|
||||
"tailscale_control_server_update_success": "Tailscale 控制伺服器已更新",
|
||||
"tailscale_dns_name": "DNS 名稱",
|
||||
"tailscale_hostname": "主機名稱",
|
||||
"tailscale_installed_not_running": "Tailscale 已安裝但未執行。",
|
||||
"tailscale_installed_not_running_state": " 狀態:{state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "需要登入",
|
||||
"tailscale_refresh": "重新整理",
|
||||
"tailscale_save": "儲存",
|
||||
"tailscale_saving": "儲存中…",
|
||||
"tailscale_stopped": "已停止",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "天",
|
||||
"time_division_hours": "小時",
|
||||
"time_division_minutes": "分鐘",
|
||||
|
||||
@@ -94,10 +94,10 @@
|
||||
"advanced_ssh_access_description": "添加您的 SSH 公钥以启用对设备的安全远程访问。",
|
||||
"advanced_ssh_access_title": "SSH 访问",
|
||||
"advanced_ssh_default_user": "默认 SSH 用户为",
|
||||
"advanced_ssh_key_required_warning": "SSH 访问需要公钥。没有公钥将无法连接。",
|
||||
"advanced_ssh_public_key_label": "SSH 公钥",
|
||||
"advanced_ssh_public_key_placeholder": "请输入您的 SSH 公钥",
|
||||
"advanced_success_download_diagnostics": "诊断文件已成功下载",
|
||||
"advanced_ssh_key_required_warning": "SSH 访问需要公钥。没有公钥将无法连接。",
|
||||
"advanced_success_loopback_disabled": "环回模式已禁用。请重启设备以应用更改。",
|
||||
"advanced_success_loopback_enabled": "环回模式已启用。请重启设备以应用更改。",
|
||||
"advanced_success_reset_config": "配置已成功恢复为默认设置。",
|
||||
@@ -826,6 +826,28 @@
|
||||
"something_went_wrong": "出错了。请稍后重试或联系技术支持。",
|
||||
"step_counter_step": "第 {step} 步",
|
||||
"subnet_mask": "子网掩码",
|
||||
"tailscale_auth_description": "Tailscale 需要进行身份验证。请打开下方链接登录。",
|
||||
"tailscale_connected": "已连接",
|
||||
"tailscale_control_server_custom": "自定义",
|
||||
"tailscale_control_server_custom_url_label": "自定义控制服务器 URL",
|
||||
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
|
||||
"tailscale_control_server_default": "默认",
|
||||
"tailscale_control_server_description": "配置 Tailscale 控制平面端点",
|
||||
"tailscale_control_server_title": "控制服务器",
|
||||
"tailscale_control_server_update_failed": "更新 Tailscale 控制服务器失败:{error}",
|
||||
"tailscale_control_server_update_success": "Tailscale 控制服务器已更新",
|
||||
"tailscale_dns_name": "DNS 名称",
|
||||
"tailscale_hostname": "主机名",
|
||||
"tailscale_installed_not_running": "Tailscale 已安装但未运行。",
|
||||
"tailscale_installed_not_running_state": " 状态:{state}",
|
||||
"tailscale_ipv4": "IPv4",
|
||||
"tailscale_ipv6": "IPv6",
|
||||
"tailscale_needs_login": "需要登录",
|
||||
"tailscale_refresh": "刷新",
|
||||
"tailscale_save": "保存",
|
||||
"tailscale_saving": "保存中...",
|
||||
"tailscale_stopped": "已停止",
|
||||
"tailscale_title": "Tailscale",
|
||||
"time_division_days": "天",
|
||||
"time_division_hours": "小时",
|
||||
"time_division_minutes": "分钟",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { cx } from "@/cva.config";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
|
||||
type SettingsItemSize = "SM" | "MD";
|
||||
|
||||
interface SettingsItemProps {
|
||||
readonly title: string;
|
||||
readonly description: string | React.ReactNode;
|
||||
@@ -9,6 +11,7 @@ interface SettingsItemProps {
|
||||
readonly className?: string;
|
||||
readonly loading?: boolean;
|
||||
readonly children?: React.ReactNode;
|
||||
readonly size?: SettingsItemSize;
|
||||
}
|
||||
|
||||
const badgeTheme = {
|
||||
@@ -27,16 +30,24 @@ export function SettingsItem(props: SettingsItemProps) {
|
||||
children,
|
||||
className,
|
||||
loading,
|
||||
size = "MD",
|
||||
} = props;
|
||||
const badgeThemeClass = badgeTheme[badgeThemeProp];
|
||||
|
||||
const isSM = size === "SM";
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cx("flex items-center justify-between gap-x-8 rounded select-none", className)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center text-base font-semibold text-black dark:text-white">
|
||||
<div
|
||||
className={cx(
|
||||
"flex items-center font-semibold text-black dark:text-white",
|
||||
isSM ? "text-sm" : "text-base",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
{badge && (
|
||||
<span
|
||||
@@ -51,7 +62,9 @@ export function SettingsItem(props: SettingsItemProps) {
|
||||
</div>
|
||||
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
|
||||
<div className={cx("text-slate-700 dark:text-slate-300", isSM ? "text-xs" : "text-sm")}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{children ? <div>{children}</div> : null}
|
||||
</label>
|
||||
|
||||
@@ -3,13 +3,28 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { TailscaleStatus } from "@hooks/stores";
|
||||
import { useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
const defaultControlURL = "https://controlplane.tailscale.com";
|
||||
const controlServerModeDefault = "default";
|
||||
const controlServerModeCustom = "custom";
|
||||
type ControlServerMode = typeof controlServerModeDefault | typeof controlServerModeCustom;
|
||||
|
||||
export default function TailscaleCard() {
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
const [status, setStatus] = useState<TailscaleStatus | null>(null);
|
||||
const [controlURLInput, setControlURLInput] = useState("");
|
||||
const [controlServerMode, setControlServerMode] =
|
||||
useState<ControlServerMode>(controlServerModeDefault);
|
||||
const [isSavingControlURL, setIsSavingControlURL] = useState(false);
|
||||
|
||||
const refreshStatus = useCallback(() => {
|
||||
send("getTailscaleStatus", {}, resp => {
|
||||
@@ -17,10 +32,38 @@ export default function TailscaleCard() {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
setStatus(resp.result as TailscaleStatus);
|
||||
const nextStatus = resp.result as TailscaleStatus;
|
||||
setStatus(nextStatus);
|
||||
const activeControlURL = nextStatus.controlURL ?? defaultControlURL;
|
||||
if (activeControlURL === defaultControlURL) {
|
||||
setControlServerMode(controlServerModeDefault);
|
||||
setControlURLInput("");
|
||||
} else {
|
||||
setControlServerMode(controlServerModeCustom);
|
||||
setControlURLInput(activeControlURL);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const saveControlURL = useCallback(() => {
|
||||
setIsSavingControlURL(true);
|
||||
const nextControlURL =
|
||||
controlServerMode === controlServerModeDefault ? "" : controlURLInput.trim();
|
||||
|
||||
send("setTailscaleControlURL", { controlURL: nextControlURL }, resp => {
|
||||
setIsSavingControlURL(false);
|
||||
if ("error" in resp) {
|
||||
const errorMessage =
|
||||
typeof resp.error.data === "string" ? resp.error.data : resp.error.message;
|
||||
notifications.error(m.tailscale_control_server_update_failed({ error: errorMessage }));
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(m.tailscale_control_server_update_success());
|
||||
refreshStatus();
|
||||
});
|
||||
}, [controlServerMode, controlURLInput, refreshStatus, send]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
@@ -39,7 +82,9 @@ export default function TailscaleCard() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">Tailscale</h3>
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{m.tailscale_title()}
|
||||
</h3>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
@@ -48,39 +93,90 @@ export default function TailscaleCard() {
|
||||
size="XS"
|
||||
theme="light"
|
||||
type="button"
|
||||
text="Refresh"
|
||||
text={m.tailscale_refresh()}
|
||||
LeadingIcon={LuRefreshCcw}
|
||||
onClick={refreshStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-slate-800/10 pt-3 dark:border-slate-300/20">
|
||||
<SettingsItem
|
||||
size="SM"
|
||||
title={m.tailscale_control_server_title()}
|
||||
description={m.tailscale_control_server_description()}
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="XS"
|
||||
label=""
|
||||
value={controlServerMode}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setControlServerMode(e.target.value as ControlServerMode)
|
||||
}
|
||||
options={[
|
||||
{ value: controlServerModeDefault, label: m.tailscale_control_server_default() },
|
||||
{ value: controlServerModeCustom, label: m.tailscale_control_server_custom() },
|
||||
]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{controlServerMode === controlServerModeCustom && (
|
||||
<NestedSettingsGroup>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label={m.tailscale_control_server_custom_url_label()}
|
||||
placeholder={m.tailscale_control_server_custom_url_placeholder()}
|
||||
value={controlURLInput}
|
||||
onChange={e => setControlURLInput(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
type="button"
|
||||
text={isSavingControlURL ? m.tailscale_saving() : m.tailscale_save()}
|
||||
disabled={isSavingControlURL}
|
||||
onClick={saveControlURL}
|
||||
/>
|
||||
</div>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.running && status.self && (
|
||||
<div className="flex-1 space-y-2">
|
||||
{status.self.hostName && (
|
||||
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">Hostname</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.tailscale_hostname()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{status.self.hostName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ipv4 && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">IPv4</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.tailscale_ipv4()}
|
||||
</span>
|
||||
<span className="font-mono text-[13px] font-medium">{ipv4}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ipv6 && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">IPv6</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.tailscale_ipv6()}
|
||||
</span>
|
||||
<span className="font-mono text-[13px] font-medium">{ipv6}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.self.dnsName && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">DNS Name</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.tailscale_dns_name()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{status.self.dnsName.replace(/\.$/, "")}
|
||||
</span>
|
||||
@@ -92,7 +188,7 @@ export default function TailscaleCard() {
|
||||
{status.backendState === "NeedsLogin" && status.authURL && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Tailscale requires authentication. Open the link below to log in.
|
||||
{m.tailscale_auth_description()}
|
||||
</p>
|
||||
<a
|
||||
href={status.authURL}
|
||||
@@ -107,8 +203,9 @@ export default function TailscaleCard() {
|
||||
|
||||
{!status.running && status.backendState !== "NeedsLogin" && (
|
||||
<p className="pt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
Tailscale is installed but not running.
|
||||
{status.backendState && ` State: ${status.backendState}`}
|
||||
{m.tailscale_installed_not_running()}
|
||||
{status.backendState &&
|
||||
m.tailscale_installed_not_running_state({ state: status.backendState })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -121,20 +218,20 @@ function StatusBadge({ status }: { status: TailscaleStatus }) {
|
||||
if (status.running) {
|
||||
return (
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Connected
|
||||
{m.tailscale_connected()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.backendState === "NeedsLogin") {
|
||||
return (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
Needs Login
|
||||
{m.tailscale_needs_login()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||||
Stopped
|
||||
{m.tailscale_stopped()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -783,6 +783,7 @@ export interface TailscaleStatus {
|
||||
running: boolean;
|
||||
backendState?: string;
|
||||
authURL?: string;
|
||||
controlURL?: string;
|
||||
self?: TailscalePeer;
|
||||
health?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user