初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+473
View File
@@ -0,0 +1,473 @@
package bootstrap
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"path/filepath"
"strings"
"sync"
"testing"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"remlink/internal/database"
"remlink/internal/identity"
"remlink/internal/ipam"
"remlink/internal/model"
)
type fakePeers struct {
mu sync.Mutex
entries map[string]netip.Addr
err error
}
func (p *fakePeers) EnsurePeer(_ context.Context, key string, address netip.Addr) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.err != nil {
return p.err
}
p.entries[key] = address
return nil
}
func TestJoinTokenEnsureRotateAndVerify(t *testing.T) {
_, store, joins, _ := testService(t)
ctx := context.Background()
first, err := joins.Ensure(ctx)
if err != nil {
t.Fatal(err)
}
again, err := joins.Ensure(ctx)
if err != nil || again != first {
t.Fatalf("second Ensure = %q, %v; want stable token", again, err)
}
rotated, err := joins.Rotate(ctx)
if err != nil {
t.Fatal(err)
}
if rotated == first {
t.Fatal("Join Token rotation returned the previous value")
}
valid, err := joins.Verify(ctx, rotated)
if err != nil || !valid {
t.Fatalf("rotated token verification = %v, %v", valid, err)
}
valid, err = joins.Verify(ctx, first)
if err != nil || valid {
t.Fatalf("revoked token verification = %v, %v", valid, err)
}
serverID, err := EnsureServerID(ctx, store)
if err != nil {
t.Fatal(err)
}
serverIDAgain, err := EnsureServerID(ctx, store)
if err != nil || serverIDAgain != serverID {
t.Fatalf("Server ID = %q, %v; want %q", serverIDAgain, err, serverID)
}
}
func TestValidateNetworkConfigRequiresOverlayControlEndpoint(t *testing.T) {
privateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatal(err)
}
valid := NetworkConfig{
ConfigVersion: 1, OverlayCIDR: "10.88.0.0/16", OverlayIP: "10.88.0.2", ServerOverlayIP: "10.88.0.1",
ServerWGPublicKey: privateKey.PublicKey().String(), ServerWGEndpoint: "203.0.113.4:51820",
ControlURL: "ws://10.88.0.1:7001/control", SessionUDPPort: 6200, MTU: 1280,
}
if err := ValidateNetworkConfig(valid); err != nil {
t.Fatal(err)
}
for _, invalidURL := range []string{
"http://10.88.0.1:7001/control", "ws://203.0.113.4:7001/control", "ws://10.88.0.1:7001/other", "ws://10.88.0.1/control",
"ws://user@10.88.0.1:7001/control", "ws://10.88.0.1:7001/control?unexpected=true", "ws://10.88.0.1:7001/control#fragment",
"ws://10.88.0.1:bad/control", "ws://10.88.0.1:7001/control?",
} {
invalid := valid
invalid.ControlURL = invalidURL
if err := ValidateNetworkConfig(invalid); err == nil {
t.Errorf("accepted invalid Control URL %q", invalidURL)
}
}
for _, invalidEndpoint := range []string{"", "missing-port", ":51820", "203.0.113.4:0", "203.0.113.4:65536"} {
invalid := valid
invalid.ServerWGEndpoint = invalidEndpoint
if err := ValidateNetworkConfig(invalid); err == nil {
t.Errorf("accepted invalid WireGuard endpoint %q", invalidEndpoint)
}
}
for name, mutate := range map[string]func(*NetworkConfig){
"Node network address": func(config *NetworkConfig) { config.OverlayIP = "10.88.0.0" },
"Node broadcast address": func(config *NetworkConfig) { config.OverlayIP = "10.88.255.255" },
"Server network address": func(config *NetworkConfig) {
config.ServerOverlayIP = "10.88.0.0"
config.ControlURL = "ws://10.88.0.0:7001/control"
},
"unusable prefix": func(config *NetworkConfig) {
config.OverlayCIDR = "10.88.0.0/31"
config.OverlayIP = "10.88.0.0"
config.ServerOverlayIP = "10.88.0.1"
config.ControlURL = "ws://10.88.0.1:7001/control"
},
"Exit Node prefix": func(config *NetworkConfig) {
config.OverlayCIDR = "0.0.0.0/0"
},
} {
invalid := valid
mutate(&invalid)
if err := ValidateNetworkConfig(invalid); err == nil {
t.Errorf("accepted %s", name)
}
}
}
func TestRegisterTenNodesAndConfigIsStable(t *testing.T) {
service, store, joins, peers := testService(t)
joinToken, err := joins.Ensure(context.Background())
if err != nil {
t.Fatal(err)
}
addresses := map[string]struct{}{}
for index := range 10 {
request := validRegisterRequest(t, index, joinToken)
response, err := service.Register(context.Background(), request)
if err != nil {
t.Fatalf("register %d: %v", index, err)
}
if _, duplicate := addresses[response.Network.OverlayIP]; duplicate {
t.Fatalf("duplicate address %s", response.Network.OverlayIP)
}
addresses[response.Network.OverlayIP] = struct{}{}
config, err := service.Config(context.Background(), ConfigRequest{NodeID: request.NodeID, NodeToken: response.NodeToken})
if err != nil {
t.Fatalf("config %d: %v", index, err)
}
if config.Network.OverlayIP != response.Network.OverlayIP || config.Network.ConfigVersion != 1 {
t.Fatalf("unstable config: register=%+v config=%+v", response.Network, config.Network)
}
}
nodes, err := store.ListNodes(context.Background())
if err != nil {
t.Fatal(err)
}
if len(nodes) != 10 || len(peers.entries) != 10 {
t.Fatalf("nodes=%d peers=%d, want 10 each", len(nodes), len(peers.entries))
}
}
func TestReregisterRotatesNodeTokenAndPreservesAddress(t *testing.T) {
service, _, joins, _ := testService(t)
joinToken, _ := joins.Ensure(context.Background())
request := validRegisterRequest(t, 1, joinToken)
first, err := service.Register(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.NodeName = "Renamed"
second, err := service.Register(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if first.Network.OverlayIP != second.Network.OverlayIP || first.NodeToken == second.NodeToken {
t.Fatalf("first=%+v second=%+v", first, second)
}
if _, err := service.Config(context.Background(), ConfigRequest{NodeID: request.NodeID, NodeToken: first.NodeToken}); !errors.Is(err, ErrNodeAuthFailed) {
t.Fatalf("old token config error = %v", err)
}
if _, err := service.Config(context.Background(), ConfigRequest{NodeID: request.NodeID, NodeToken: second.NodeToken}); err != nil {
t.Fatalf("new token config: %v", err)
}
}
func TestConfigReconcilesOldNodeRuntimeBeforeBootstrap(t *testing.T) {
service, _, joins, _ := testService(t)
joinToken, err := joins.Ensure(context.Background())
if err != nil {
t.Fatal(err)
}
registered, err := service.Register(context.Background(), validRegisterRequest(t, 77, joinToken))
if err != nil {
t.Fatal(err)
}
var reconciledNode string
service.SetNodeBootstrapHandler(func(_ context.Context, nodeID string) error {
reconciledNode = nodeID
return nil
})
request := ConfigRequest{NodeID: "00000000-0000-4000-8000-000000000077", NodeToken: registered.NodeToken}
if _, err := service.Config(context.Background(), request); err != nil {
t.Fatal(err)
}
if reconciledNode != request.NodeID {
t.Fatalf("reconciled Node = %q, want %q", reconciledNode, request.NodeID)
}
service.SetNodeBootstrapHandler(func(context.Context, string) error { return errors.New("Session cleanup failed") })
if _, err := service.Config(context.Background(), request); err == nil {
t.Fatal("Bootstrap ignored Node runtime reconciliation failure")
}
}
func TestPeerFailureRollsBackNewRegistration(t *testing.T) {
service, store, joins, peers := testService(t)
peers.err = errors.New("kernel unavailable")
joinToken, _ := joins.Ensure(context.Background())
request := validRegisterRequest(t, 2, joinToken)
if _, err := service.Register(context.Background(), request); err == nil {
t.Fatal("registration unexpectedly succeeded")
}
if _, err := store.GetNode(context.Background(), request.NodeID); !errors.Is(err, database.ErrNodeNotFound) {
t.Fatalf("failed registration persisted: %v", err)
}
}
func TestHTTPContractAndStrictJSON(t *testing.T) {
service, _, joins, _ := testService(t)
joinToken, _ := joins.Ensure(context.Background())
handler := Handler(service)
info := httptest.NewRecorder()
handler.ServeHTTP(info, httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil))
if info.Code != http.StatusOK || info.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("server info status=%d headers=%v", info.Code, info.Header())
}
request := validRegisterRequest(t, 4, joinToken)
registered := performJSON(t, handler, http.MethodPost, "/api/v1/bootstrap/register", request)
if registered.Code != http.StatusCreated {
t.Fatalf("register status=%d body=%s", registered.Code, registered.Body.String())
}
var response RegisterResponse
if err := json.Unmarshal(registered.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
configured := performJSON(t, handler, http.MethodPost, "/api/v1/bootstrap/config", ConfigRequest{
NodeID: request.NodeID, NodeToken: response.NodeToken,
})
if configured.Code != http.StatusOK {
t.Fatalf("config status=%d body=%s", configured.Code, configured.Body.String())
}
unknownField := httptest.NewRecorder()
unknownBody := bytes.NewBufferString(`{"node_id":"x","node_token":"x","surprise":true}`)
unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/bootstrap/config", unknownBody)
unknownRequest.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(unknownField, unknownRequest)
if unknownField.Code != http.StatusBadRequest {
t.Fatalf("unknown JSON field status=%d", unknownField.Code)
}
}
func TestBootstrapClient(t *testing.T) {
service, _, joins, _ := testService(t)
server := httptest.NewServer(Handler(service))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
info, err := client.ServerInfo(ctx)
if err != nil || info.APIVersion != 1 {
t.Fatalf("ServerInfo = %+v, %v", info, err)
}
joinToken, _ := joins.Ensure(ctx)
request := validRegisterRequest(t, 8, joinToken)
registered, err := client.Register(ctx, request)
if err != nil {
t.Fatal(err)
}
configured, err := client.Config(ctx, ConfigRequest{NodeID: request.NodeID, NodeToken: registered.NodeToken})
if err != nil || configured.Network.OverlayIP != registered.Network.OverlayIP {
t.Fatalf("Config = %+v, %v", configured, err)
}
_, err = client.Config(ctx, ConfigRequest{NodeID: request.NodeID, NodeToken: "wrong"})
var clientError *ClientError
if !errors.As(err, &clientError) || clientError.Status != http.StatusUnauthorized {
t.Fatalf("wrong-token error = %#v", err)
}
}
func TestEnrollPersistsIdentityAndUsesConfigOnRestart(t *testing.T) {
service, _, joins, _ := testService(t)
server := httptest.NewServer(Handler(service))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
identityStore, err := identity.NewStore(filepath.Join(t.TempDir(), "identity.json"), xorProtector{})
if err != nil {
t.Fatal(err)
}
joinToken, _ := joins.Ensure(context.Background())
config := EnrollConfig{
NodeType: model.NodeTypeSite, NodeName: "Site-A", ServerURL: server.URL,
JoinToken: joinToken, Version: "test", OSVersion: "windows/amd64",
}
firstIdentity, firstNetwork, err := Enroll(context.Background(), identityStore, client, config)
if err != nil {
t.Fatal(err)
}
if firstIdentity.NodeToken == "" || firstIdentity.ConfigVersion != firstNetwork.ConfigVersion {
t.Fatalf("incomplete enrolled identity: %+v", firstIdentity)
}
config.JoinToken = ""
secondIdentity, secondNetwork, err := Enroll(context.Background(), identityStore, client, config)
if err != nil {
t.Fatal(err)
}
if secondIdentity.NodeID != firstIdentity.NodeID || secondIdentity.NodeToken != firstIdentity.NodeToken ||
secondNetwork.OverlayIP != firstNetwork.OverlayIP {
t.Fatalf("restart changed identity/config: first=%+v/%+v second=%+v/%+v", firstIdentity, firstNetwork, secondIdentity, secondNetwork)
}
}
func TestEnrollRebindsOnlyUnregisteredPortableIdentity(t *testing.T) {
service, _, joins, _ := testService(t)
server := httptest.NewServer(Handler(service))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
identityStore, err := identity.NewStore(filepath.Join(t.TempDir(), "identity.json"), xorProtector{})
if err != nil {
t.Fatal(err)
}
draft, err := identity.New(model.NodeTypeEngineer, "Engineer-A", "http://192.0.2.1:8080")
if err != nil {
t.Fatal(err)
}
if err := identityStore.Save(draft); err != nil {
t.Fatal(err)
}
joinToken, _ := joins.Ensure(context.Background())
config := EnrollConfig{
NodeType: model.NodeTypeEngineer, NodeName: "Engineer-A", ServerURL: server.URL,
JoinToken: joinToken, Version: "test", OSVersion: "windows/amd64",
}
registered, _, err := Enroll(context.Background(), identityStore, client, config)
if err != nil {
t.Fatalf("rebind unregistered identity: %v", err)
}
if registered.NodeID != draft.NodeID || registered.ServerURL != server.URL || registered.NodeToken == "" {
t.Fatalf("unexpected rebound identity: %+v", registered)
}
config.ServerURL = "http://192.0.2.2:8080"
config.JoinToken = ""
if _, _, err := Enroll(context.Background(), identityStore, client, config); err == nil ||
!strings.Contains(err.Error(), "已注册的 Node 身份") {
t.Fatalf("registered identity Server change error = %v", err)
}
}
func TestUpdateNetworkRejectsInvalidBootstrapTrustBoundary(t *testing.T) {
service, _, _, _ := testService(t)
original := service.NetworkSnapshot()
tests := []struct {
name string
mutate func(*ServiceConfig)
}{
{"exit-node-overlay", func(c *ServiceConfig) { c.OverlayCIDR = netip.MustParsePrefix("0.0.0.0/0") }},
{"network-server-address", func(c *ServiceConfig) { c.ServerOverlayIP = netip.MustParseAddr("10.88.0.0") }},
{"wireguard-endpoint", func(c *ServiceConfig) { c.WGEndpoint = "missing-port" }},
{"control-host", func(c *ServiceConfig) { c.ControlURL = "ws://203.0.113.1:7001/control" }},
{"control-user", func(c *ServiceConfig) { c.ControlURL = "ws://user@10.88.0.1:7001/control" }},
{"control-empty-query", func(c *ServiceConfig) { c.ControlURL = "ws://10.88.0.1:7001/control?" }},
{"session-port", func(c *ServiceConfig) { c.SessionUDPPort = 0 }},
{"mtu", func(c *ServiceConfig) { c.MTU = 0 }},
{"config-version", func(c *ServiceConfig) { c.ConfigVersion = 0 }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := original
test.mutate(&candidate)
if err := service.UpdateNetwork(candidate); err == nil {
t.Fatal("invalid Bootstrap network update was accepted")
}
if current := service.NetworkSnapshot(); current != original {
t.Fatalf("rejected update changed Bootstrap snapshot: %+v", current)
}
})
}
}
type xorProtector struct{}
func (xorProtector) Protect(value []byte) ([]byte, error) { return xor(value), nil }
func (xorProtector) Unprotect(value []byte) ([]byte, error) { return xor(value), nil }
func xor(value []byte) []byte {
result := append([]byte(nil), value...)
for index := range result {
result[index] ^= 0xA5
}
return result
}
func testService(t *testing.T) (*Service, *database.Store, *JoinTokens, *fakePeers) {
t.Helper()
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "remlink.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
store := database.NewStore(db)
manager, err := ipam.New(store, netip.MustParsePrefix("10.88.0.0/16"), netip.MustParseAddr("10.88.0.1"))
if err != nil {
t.Fatal(err)
}
serverPrivate, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatal(err)
}
peers := &fakePeers{entries: make(map[string]netip.Addr)}
joins := NewJoinTokens(store)
service, err := NewService(store, manager, joins, peers, ServiceConfig{
ServerID: "23ac1928-2334-49bb-8b3f-272572d919da", Version: "test",
WGPublicKey: serverPrivate.PublicKey().String(), WGEndpoint: "203.0.113.1:51820",
OverlayCIDR: netip.MustParsePrefix("10.88.0.0/16"), ServerOverlayIP: netip.MustParseAddr("10.88.0.1"),
ControlURL: "ws://10.88.0.1:7001/control", SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1,
})
if err != nil {
t.Fatal(err)
}
return service, store, joins, peers
}
func validRegisterRequest(t *testing.T, index int, joinToken string) RegisterRequest {
t.Helper()
privateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatal(err)
}
return RegisterRequest{
JoinToken: joinToken, NodeID: fmt.Sprintf("00000000-0000-4000-8000-%012d", index),
NodeType: model.NodeTypeEngineer, NodeName: fmt.Sprintf("Engineer-%d", index),
WGPublicKey: privateKey.PublicKey().String(), Version: "test", OSVersion: "Windows test",
}
}
func performJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, bytes.NewReader(encoded))
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
return recorder
}
+117
View File
@@ -0,0 +1,117 @@
package bootstrap
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
appconfig "remlink/internal/config"
)
const maxResponseBody = 1 << 20
// Client calls the public Bootstrap API before Overlay connectivity exists.
type Client struct {
baseURL string
http *http.Client
}
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if err := appconfig.ValidateServerURL(baseURL); err != nil {
return nil, fmt.Errorf("invalid Bootstrap base URL: %w", err)
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
return &Client{baseURL: baseURL, http: httpClient}, nil
}
func (c *Client) ServerInfo(ctx context.Context) (ServerInfo, error) {
var response ServerInfo
if err := c.do(ctx, http.MethodGet, "/api/v1/server/info", nil, &response); err != nil {
return ServerInfo{}, err
}
return response, nil
}
func (c *Client) Register(ctx context.Context, request RegisterRequest) (RegisterResponse, error) {
var response RegisterResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/bootstrap/register", request, &response); err != nil {
return RegisterResponse{}, err
}
return response, nil
}
func (c *Client) Config(ctx context.Context, request ConfigRequest) (ConfigResponse, error) {
var response ConfigResponse
if err := c.do(ctx, http.MethodPost, "/api/v1/bootstrap/config", request, &response); err != nil {
return ConfigResponse{}, err
}
return response, nil
}
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("encode Bootstrap request: %w", err)
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("create Bootstrap request: %w", err)
}
request.Header.Set("Accept", "application/json")
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("call Bootstrap API: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBody+1)
raw, err := io.ReadAll(limited)
if err != nil {
return fmt.Errorf("read Bootstrap response: %w", err)
}
if len(raw) > maxResponseBody {
return errors.New("Bootstrap response exceeds 1 MiB")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
var envelope apiErrorEnvelope
if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Code != "" {
return &ClientError{Status: response.StatusCode, Code: envelope.Error.Code, Message: envelope.Error.Message}
}
return &ClientError{Status: response.StatusCode, Code: "HTTP_ERROR", Message: strings.TrimSpace(string(raw))}
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
return fmt.Errorf("decode Bootstrap response: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("Bootstrap response must contain one JSON value")
}
return nil
}
// ClientError preserves the API status and machine-readable code.
type ClientError struct {
Status int
Code string
Message string
}
func (e *ClientError) Error() string {
return fmt.Sprintf("Bootstrap API %s (HTTP %d): %s", e.Code, e.Status, e.Message)
}
+146
View File
@@ -0,0 +1,146 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"remlink/internal/identity"
"remlink/internal/model"
)
// EnrollConfig contains first-run inputs which are deliberately not persisted
// as plaintext secrets. JoinToken is used only if identity has no NodeToken.
type EnrollConfig struct {
NodeType model.NodeType
NodeName string
ServerURL string
JoinToken string
Version string
OSVersion string
}
// Enroll loads or creates an identity, calls register/config, validates the
// authoritative NetworkConfig, and persists the latest token/version.
func Enroll(ctx context.Context, store *identity.Store, client *Client, config EnrollConfig) (identity.Identity, NetworkConfig, error) {
current, err := store.Load()
if errors.Is(err, os.ErrNotExist) {
current, err = identity.New(config.NodeType, config.NodeName, config.ServerURL)
if err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
if err := store.Save(current); err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
} else if err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
if current.NodeType != config.NodeType {
return identity.Identity{}, NetworkConfig{}, errors.New("persisted Node type does not match this executable")
}
configuredServerURL := strings.TrimRight(strings.TrimSpace(config.ServerURL), "/")
if configuredServerURL != current.ServerURL {
// A first launch may create identity.json before the operator has filled
// in the real Server URL and Join Token. Until registration succeeds the
// identity has no server-side credentials or owned routes, so rebinding
// that local draft identity is safe and makes portable packages editable.
if current.NodeToken == "" && current.ConfigVersion == 0 && len(current.OwnedRoutes) == 0 {
current.ServerURL = configuredServerURL
if err := store.Save(current); err != nil {
return identity.Identity{}, NetworkConfig{}, fmt.Errorf("update unregistered Node identity Server URL: %w", err)
}
} else {
return identity.Identity{}, NetworkConfig{}, errors.New("配置的 Server URL 与已注册的 Node 身份不一致;如需切换 Server,请退出程序后删除 identity.json 并重新注册")
}
}
current.NodeName = strings.TrimSpace(config.NodeName)
var network NetworkConfig
if current.NodeToken == "" {
if config.JoinToken == "" {
return identity.Identity{}, NetworkConfig{}, errors.New("Join Token is required for first registration")
}
response, err := client.Register(ctx, RegisterRequest{
JoinToken: config.JoinToken, NodeID: current.NodeID, NodeType: current.NodeType,
NodeName: current.NodeName, WGPublicKey: current.PublicKey(),
Version: config.Version, OSVersion: config.OSVersion,
})
if err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
current.NodeToken = response.NodeToken
network = response.Network
} else {
response, err := client.Config(ctx, ConfigRequest{NodeID: current.NodeID, NodeToken: current.NodeToken})
if err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
network = response.Network
}
if err := ValidateNetworkConfig(network); err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
current.ConfigVersion = network.ConfigVersion
if err := store.Save(current); err != nil {
return identity.Identity{}, NetworkConfig{}, err
}
return current, network, nil
}
// ValidateNetworkConfig rejects malformed or internally inconsistent Server data.
func ValidateNetworkConfig(config NetworkConfig) error {
prefix, err := netip.ParsePrefix(config.OverlayCIDR)
if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 || prefix.Bits() > 30 {
return errors.New("NetworkConfig overlay_cidr must be a canonical IPv4 prefix")
}
nodeIP, err := netip.ParseAddr(config.OverlayIP)
if err != nil || !usableOverlayAddress(prefix, nodeIP) {
return errors.New("NetworkConfig overlay_ip must belong to overlay_cidr")
}
serverIP, err := netip.ParseAddr(config.ServerOverlayIP)
if err != nil || !usableOverlayAddress(prefix, serverIP) || serverIP == nodeIP {
return errors.New("NetworkConfig server_overlay_ip must be a distinct address in overlay_cidr")
}
if _, err := wgtypes.ParseKey(config.ServerWGPublicKey); err != nil {
return fmt.Errorf("NetworkConfig Server WireGuard key: %w", err)
}
if config.ServerWGEndpoint == "" || config.ControlURL == "" {
return errors.New("NetworkConfig endpoints must not be empty")
}
wgHost, wgPortText, err := net.SplitHostPort(config.ServerWGEndpoint)
if err != nil || strings.TrimSpace(wgHost) == "" {
return errors.New("NetworkConfig server_wg_endpoint must be host:port")
}
wgPort, err := strconv.Atoi(wgPortText)
if err != nil || wgPort < 1 || wgPort > 65535 {
return errors.New("NetworkConfig server_wg_endpoint port is invalid")
}
controlURL, err := url.Parse(config.ControlURL)
if err != nil || (controlURL.Scheme != "ws" && controlURL.Scheme != "wss") || controlURL.Path != "/control" ||
controlURL.Opaque != "" || controlURL.User != nil || controlURL.RawQuery != "" || controlURL.ForceQuery || controlURL.Fragment != "" {
return errors.New("NetworkConfig control_url must be an absolute ws(s) /control URL")
}
controlHost, controlPortText, err := net.SplitHostPort(controlURL.Host)
if err != nil || controlHost != serverIP.String() {
return errors.New("NetworkConfig control_url must target server_overlay_ip with an explicit port")
}
controlPort, err := strconv.Atoi(controlPortText)
if err != nil || controlPort < 1 || controlPort > 65535 {
return errors.New("NetworkConfig control_url port is invalid")
}
if config.ConfigVersion == 0 || config.SessionUDPPort < 1 || config.SessionUDPPort > 65535 || config.MTU < 576 || config.MTU > 65535 {
return errors.New("NetworkConfig version, UDP port, or MTU is invalid")
}
return nil
}
// CurrentOSVersion is a non-secret Bootstrap capability label.
func CurrentOSVersion() string { return runtime.GOOS + "/" + runtime.GOARCH }
+108
View File
@@ -0,0 +1,108 @@
package bootstrap
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"remlink/internal/protocol"
)
const maxRequestBody = 1 << 20
// Handler exposes the exact v1 public Bootstrap routes.
func Handler(service *Service) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server/info", func(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusOK, service.ServerInfo())
})
mux.HandleFunc("POST /api/v1/bootstrap/register", func(writer http.ResponseWriter, request *http.Request) {
var input RegisterRequest
if err := decodeJSON(writer, request, &input); err != nil {
writeAPIError(writer, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
output, err := service.Register(request.Context(), input)
if err != nil {
switch {
case errors.Is(err, ErrJoinTokenInvalid):
writeAPIError(writer, http.StatusUnauthorized, string(protocol.ErrorJoinTokenInvalid), err.Error())
case errors.Is(err, ErrNodeConflict):
writeAPIError(writer, http.StatusConflict, "NODE_CONFLICT", err.Error())
default:
writeAPIError(writer, http.StatusBadRequest, "REGISTRATION_FAILED", err.Error())
}
return
}
writeJSON(writer, http.StatusCreated, output)
})
mux.HandleFunc("POST /api/v1/bootstrap/config", func(writer http.ResponseWriter, request *http.Request) {
var input ConfigRequest
if err := decodeJSON(writer, request, &input); err != nil {
writeAPIError(writer, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
return
}
output, err := service.Config(request.Context(), input)
if err != nil {
if errors.Is(err, ErrNodeAuthFailed) {
writeAPIError(writer, http.StatusUnauthorized, string(protocol.ErrorNodeAuthFailed), err.Error())
return
}
writeAPIError(writer, http.StatusInternalServerError, "CONFIG_FAILED", err.Error())
return
}
writeJSON(writer, http.StatusOK, output)
})
return securityHeaders(mux)
}
func decodeJSON(writer http.ResponseWriter, request *http.Request, destination any) error {
if contentType := request.Header.Get("Content-Type"); contentType != "" &&
!strings.HasPrefix(strings.ToLower(contentType), "application/json") {
return errors.New("Content-Type must be application/json")
}
request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBody)
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request body must contain one JSON object")
}
return nil
}
type apiErrorEnvelope struct {
Error apiError `json:"error"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeAPIError(writer http.ResponseWriter, status int, code, message string) {
writeJSONStatus(writer, status, apiErrorEnvelope{Error: apiError{Code: code, Message: message}})
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writeJSONStatus(writer, status, value)
}
func writeJSONStatus(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(writer, request)
})
}
+30
View File
@@ -0,0 +1,30 @@
package bootstrap
import (
"context"
"github.com/google/uuid"
"remlink/internal/database"
)
const serverIDSetting = "server_id"
// EnsureServerID returns the stable Server UUID stored in SQLite.
func EnsureServerID(ctx context.Context, store *database.Store) (string, error) {
value, found, err := database.GetSetting(ctx, store.DB(), serverIDSetting)
if err != nil {
return "", err
}
if found {
if _, err := uuid.Parse(value); err != nil {
return "", err
}
return value, nil
}
value = uuid.NewString()
if err := database.SetSetting(ctx, store.DB(), serverIDSetting, value); err != nil {
return "", err
}
return value, nil
}
+342
View File
@@ -0,0 +1,342 @@
// Package bootstrap implements the public Node enrollment and configuration API.
package bootstrap
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"strconv"
"strings"
"sync"
"github.com/google/uuid"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"remlink/internal/database"
"remlink/internal/ipam"
"remlink/internal/model"
)
var (
ErrJoinTokenInvalid = errors.New("Join Token invalid")
ErrNodeAuthFailed = errors.New("Node authentication failed")
ErrNodeConflict = errors.New("Node identity conflicts with existing registration")
)
// PeerManager is the narrow kernel-WireGuard boundary used by Bootstrap.
type PeerManager interface {
EnsurePeer(context.Context, string, netip.Addr) error
}
// ServiceConfig describes the Server values returned to Windows nodes.
type ServiceConfig struct {
ServerID string
Version string
WGPublicKey string
WGEndpoint string
OverlayCIDR netip.Prefix
ServerOverlayIP netip.Addr
ControlURL string
SessionUDPPort int
MTU int
ConfigVersion uint64
}
// Service coordinates registration, IPAM, token rotation, and peer creation.
type Service struct {
mu sync.RWMutex
store *database.Store
ipam *ipam.Manager
joins *JoinTokens
peers PeerManager
config ServiceConfig
onNodeBootstrap func(context.Context, string) error
}
// SetNodeBootstrapHandler installs the Server runtime reconciliation boundary.
// A Node calls the public Config endpoint only when constructing a fresh local
// runtime; any old in-memory Session for that Node can no longer be resumed.
func (s *Service) SetNodeBootstrapHandler(handler func(context.Context, string) error) {
s.mu.Lock()
s.onNodeBootstrap = handler
s.mu.Unlock()
}
func NewService(store *database.Store, ipamManager *ipam.Manager, joins *JoinTokens, peers PeerManager, config ServiceConfig) (*Service, error) {
if store == nil || ipamManager == nil || joins == nil || peers == nil {
return nil, errors.New("Bootstrap dependencies must not be nil")
}
if err := validateServiceConfig(config); err != nil {
return nil, err
}
return &Service{store: store, ipam: ipamManager, joins: joins, peers: peers, config: config}, nil
}
func usableOverlayAddress(prefix netip.Prefix, address netip.Addr) bool {
if !address.Is4() || !prefix.Contains(address) || address == prefix.Addr() {
return false
}
base := prefix.Masked().Addr().As4()
value := uint32(base[0])<<24 | uint32(base[1])<<16 | uint32(base[2])<<8 | uint32(base[3])
value |= ^uint32(0) >> prefix.Bits()
broadcast := netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)})
return address != broadcast
}
type RegisterRequest struct {
JoinToken string `json:"join_token"`
NodeID string `json:"node_id"`
NodeType model.NodeType `json:"node_type"`
NodeName string `json:"node_name"`
WGPublicKey string `json:"wg_public_key"`
Version string `json:"version"`
OSVersion string `json:"os_version"`
}
type ConfigRequest struct {
NodeID string `json:"node_id"`
NodeToken string `json:"node_token"`
}
type ServerInfo struct {
ServerID string `json:"server_id"`
Version string `json:"version"`
APIVersion int `json:"api_version"`
WGEndpoint string `json:"wg_endpoint"`
RegistrationURL string `json:"registration_url"`
}
type NetworkConfig struct {
ConfigVersion uint64 `json:"config_version"`
OverlayCIDR string `json:"overlay_cidr"`
OverlayIP string `json:"overlay_ip"`
ServerOverlayIP string `json:"server_overlay_ip"`
ServerWGPublicKey string `json:"server_wg_public_key"`
ServerWGEndpoint string `json:"server_wg_endpoint"`
ControlURL string `json:"control_url"`
SessionUDPPort int `json:"session_udp_port"`
MTU int `json:"mtu"`
}
type RegisterResponse struct {
NodeToken string `json:"node_token"`
Network NetworkConfig `json:"network_config"`
}
type ConfigResponse struct {
Network NetworkConfig `json:"network_config"`
}
func (s *Service) ServerInfo() ServerInfo {
s.mu.RLock()
defer s.mu.RUnlock()
return ServerInfo{
ServerID: s.config.ServerID, Version: s.config.Version, APIVersion: 1,
WGEndpoint: s.config.WGEndpoint, RegistrationURL: "/api/v1/bootstrap/register",
}
}
// Register enrolls a new Node or safely rotates credentials for an identical identity.
func (s *Service) Register(ctx context.Context, request RegisterRequest) (RegisterResponse, error) {
validJoin, err := s.joins.Verify(ctx, request.JoinToken)
if err != nil {
return RegisterResponse{}, err
}
if !validJoin {
return RegisterResponse{}, ErrJoinTokenInvalid
}
request.NodeName = strings.TrimSpace(request.NodeName)
if err := validateRegistration(request); err != nil {
return RegisterResponse{}, err
}
key, _ := wgtypes.ParseKey(request.WGPublicKey)
canonicalKey := key.String()
plainToken, tokenHash, err := newNodeToken()
if err != nil {
return RegisterResponse{}, err
}
existing, err := s.store.GetNode(ctx, request.NodeID)
if err == nil {
if existing.Type != request.NodeType || existing.WGPublicKey != canonicalKey {
return RegisterResponse{}, ErrNodeConflict
}
if err := s.reconcileNodeBootstrap(ctx, existing.ID); err != nil {
return RegisterResponse{}, err
}
if err := s.peers.EnsurePeer(ctx, canonicalKey, existing.OverlayIP); err != nil {
return RegisterResponse{}, fmt.Errorf("ensure existing WireGuard peer: %w", err)
}
existing.Name = request.NodeName
existing.NodeTokenHash = tokenHash
existing.Version = request.Version
existing.OSVersion = request.OSVersion
if err := s.store.UpdateNodeRegistration(ctx, existing); err != nil {
return RegisterResponse{}, err
}
return RegisterResponse{NodeToken: plainToken, Network: s.network(existing.OverlayIP)}, nil
}
if !errors.Is(err, database.ErrNodeNotFound) {
return RegisterResponse{}, err
}
s.mu.RLock()
ipamManager := s.ipam
s.mu.RUnlock()
node, created, err := ipamManager.ReserveNode(ctx, model.Node{
ID: request.NodeID, Type: request.NodeType, Name: request.NodeName,
WGPublicKey: canonicalKey, NodeTokenHash: tokenHash, Status: model.NodeOffline,
Version: request.Version, OSVersion: request.OSVersion,
})
if err != nil {
return RegisterResponse{}, err
}
if !created {
return RegisterResponse{}, ErrNodeConflict
}
if err := s.peers.EnsurePeer(ctx, canonicalKey, node.OverlayIP); err != nil {
if rollbackErr := ipamManager.ReleaseNode(ctx, node.ID); rollbackErr != nil {
return RegisterResponse{}, fmt.Errorf("ensure WireGuard peer: %w (registration rollback failed: %v)", err, rollbackErr)
}
return RegisterResponse{}, fmt.Errorf("ensure WireGuard peer: %w", err)
}
return RegisterResponse{NodeToken: plainToken, Network: s.network(node.OverlayIP)}, nil
}
// Config authenticates an enrolled Node and returns the authoritative latest configuration.
func (s *Service) Config(ctx context.Context, request ConfigRequest) (ConfigResponse, error) {
node, err := s.AuthenticateNode(ctx, request.NodeID, request.NodeToken)
if err != nil {
return ConfigResponse{}, err
}
if err := s.reconcileNodeBootstrap(ctx, node.ID); err != nil {
return ConfigResponse{}, err
}
if err := s.peers.EnsurePeer(ctx, node.WGPublicKey, node.OverlayIP); err != nil {
return ConfigResponse{}, fmt.Errorf("ensure WireGuard peer: %w", err)
}
return ConfigResponse{Network: s.network(node.OverlayIP)}, nil
}
func (s *Service) reconcileNodeBootstrap(ctx context.Context, nodeID string) error {
s.mu.RLock()
handler := s.onNodeBootstrap
s.mu.RUnlock()
if handler == nil {
return nil
}
if err := handler(ctx, nodeID); err != nil {
return fmt.Errorf("reconcile Node runtime before Bootstrap: %w", err)
}
return nil
}
// AuthenticateNode verifies application identity for Bootstrap and Control.
func (s *Service) AuthenticateNode(ctx context.Context, nodeID, nodeToken string) (model.Node, error) {
node, err := s.store.GetNode(ctx, nodeID)
if err != nil {
if errors.Is(err, database.ErrNodeNotFound) {
return model.Node{}, ErrNodeAuthFailed
}
return model.Node{}, err
}
if !nodeTokenMatches(node.NodeTokenHash, nodeToken) {
return model.Node{}, ErrNodeAuthFailed
}
return node, nil
}
func validateRegistration(request RegisterRequest) error {
if _, err := uuid.Parse(request.NodeID); err != nil {
return fmt.Errorf("node_id must be a UUID: %w", err)
}
if !request.NodeType.Valid() {
return fmt.Errorf("invalid node_type %q", request.NodeType)
}
if request.NodeName == "" || len(request.NodeName) > 128 {
return errors.New("node_name must contain 1 to 128 bytes")
}
if _, err := wgtypes.ParseKey(request.WGPublicKey); err != nil {
return fmt.Errorf("wg_public_key must be a WireGuard key: %w", err)
}
return nil
}
func (s *Service) network(nodeIP netip.Addr) NetworkConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.networkLocked(nodeIP)
}
func (s *Service) networkLocked(nodeIP netip.Addr) NetworkConfig {
return NetworkConfig{
ConfigVersion: s.config.ConfigVersion, OverlayCIDR: s.config.OverlayCIDR.String(),
OverlayIP: nodeIP.String(), ServerOverlayIP: s.config.ServerOverlayIP.String(),
ServerWGPublicKey: s.config.WGPublicKey, ServerWGEndpoint: s.config.WGEndpoint,
ControlURL: s.config.ControlURL, SessionUDPPort: s.config.SessionUDPPort, MTU: s.config.MTU,
}
}
// NetworkSnapshot returns the current public configuration without a Node IP.
func (s *Service) NetworkSnapshot() ServiceConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config
}
// UpdateNetwork publishes a completed Server network migration to Bootstrap.
func (s *Service) UpdateNetwork(config ServiceConfig) error {
if err := validateServiceConfig(config); err != nil {
return fmt.Errorf("invalid updated Bootstrap network: %w", err)
}
s.mu.Lock()
s.config = config
s.mu.Unlock()
return nil
}
func validateServiceConfig(config ServiceConfig) error {
if _, err := uuid.Parse(config.ServerID); err != nil {
return fmt.Errorf("invalid Server ID: %w", err)
}
if strings.TrimSpace(config.Version) == "" {
return errors.New("Bootstrap Server version is required")
}
if _, err := wgtypes.ParseKey(config.WGPublicKey); err != nil {
return fmt.Errorf("invalid Server WireGuard public key: %w", err)
}
if !config.OverlayCIDR.IsValid() || !config.OverlayCIDR.Addr().Is4() || config.OverlayCIDR != config.OverlayCIDR.Masked() || config.OverlayCIDR.Bits() == 0 || config.OverlayCIDR.Bits() > 30 {
return errors.New("Bootstrap overlay CIDR must be a canonical IPv4 prefix with usable hosts")
}
if !usableOverlayAddress(config.OverlayCIDR, config.ServerOverlayIP) {
return errors.New("Bootstrap Server overlay address must be usable inside overlay CIDR")
}
wgHost, wgPortText, err := net.SplitHostPort(config.WGEndpoint)
if err != nil || strings.TrimSpace(wgHost) == "" {
return errors.New("Bootstrap WireGuard endpoint must be host:port")
}
wgPort, err := strconv.Atoi(wgPortText)
if err != nil || wgPort < 1 || wgPort > 65535 {
return errors.New("Bootstrap WireGuard endpoint port is invalid")
}
controlURL, err := url.Parse(config.ControlURL)
if err != nil || (controlURL.Scheme != "ws" && controlURL.Scheme != "wss") || controlURL.Path != "/control" ||
controlURL.Opaque != "" || controlURL.User != nil || controlURL.RawQuery != "" || controlURL.ForceQuery || controlURL.Fragment != "" {
return errors.New("Bootstrap Control URL must be an absolute ws(s) /control URL")
}
controlHost, controlPortText, err := net.SplitHostPort(controlURL.Host)
if err != nil || controlHost != config.ServerOverlayIP.String() {
return errors.New("Bootstrap Control URL must target the Server overlay IP")
}
controlPort, err := strconv.Atoi(controlPortText)
if err != nil || controlPort < 1 || controlPort > 65535 {
return errors.New("Bootstrap Control URL port is invalid")
}
if config.SessionUDPPort < 1 || config.SessionUDPPort > 65535 || config.MTU < 576 || config.MTU > 65535 || config.ConfigVersion == 0 {
return errors.New("Bootstrap Session UDP port, MTU, and config version must be valid")
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package bootstrap
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"sync"
"remlink/internal/database"
)
const joinTokenSetting = "join_token"
// JoinTokens maintains the single rotatable enrollment secret in settings.
type JoinTokens struct {
mu sync.Mutex
db databaseSettings
}
type databaseSettings interface {
Get(context.Context, string) (string, bool, error)
Set(context.Context, string, string) error
}
type settingsAdapter struct{ store *database.Store }
func (a settingsAdapter) Get(ctx context.Context, key string) (string, bool, error) {
return database.GetSetting(ctx, a.store.DB(), key)
}
func (a settingsAdapter) Set(ctx context.Context, key, value string) error {
return database.SetSetting(ctx, a.store.DB(), key, value)
}
// NewJoinTokens binds token management to Server settings.
func NewJoinTokens(store *database.Store) *JoinTokens {
return &JoinTokens{db: settingsAdapter{store: store}}
}
// Ensure returns the current Join Token, generating it on first startup.
func (m *JoinTokens) Ensure(ctx context.Context) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
value, found, err := m.db.Get(ctx, joinTokenSetting)
if err != nil {
return "", err
}
if found {
return value, nil
}
return m.rotateLocked(ctx)
}
// Rotate revokes the previous Join Token and returns a new one.
func (m *JoinTokens) Rotate(ctx context.Context) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.rotateLocked(ctx)
}
// Verify compares a submitted token without data-dependent early exit.
func (m *JoinTokens) Verify(ctx context.Context, submitted string) (bool, error) {
current, err := m.Ensure(ctx)
if err != nil {
return false, err
}
return subtle.ConstantTimeCompare([]byte(current), []byte(submitted)) == 1, nil
}
func (m *JoinTokens) rotateLocked(ctx context.Context) (string, error) {
value, err := randomToken()
if err != nil {
return "", err
}
if err := m.db.Set(ctx, joinTokenSetting, value); err != nil {
return "", err
}
return value, nil
}
func randomToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate random token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func newNodeToken() (plain string, hash []byte, err error) {
plain, err = randomToken()
if err != nil {
return "", nil, err
}
digest := sha256.Sum256([]byte(plain))
return plain, digest[:], nil
}
func nodeTokenMatches(storedHash []byte, submitted string) bool {
if len(storedHash) != sha256.Size || submitted == "" {
return false
}
digest := sha256.Sum256([]byte(submitted))
return subtle.ConstantTimeCompare(storedHash, digest[:]) == 1
}