初版功能完成
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user