初版功能完成
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
+265
View File
@@ -0,0 +1,265 @@
// Package identity persists the Windows Node identity without plaintext secrets.
package identity
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"remlink/internal/config"
"remlink/internal/model"
)
const diskVersion = 1
// Protector is implemented by the Windows DPAPI adapter.
type Protector interface {
Protect([]byte) ([]byte, error)
Unprotect([]byte) ([]byte, error)
}
// Identity is the durable Node identity used by Bootstrap and reconciliation.
type Identity struct {
NodeID string
NodeType model.NodeType
NodeName string
PrivateKey wgtypes.Key
NodeToken string
ServerURL string
ConfigVersion uint64
OwnedRoutes []string
}
// PublicKey derives the shareable WireGuard key from the protected private key.
func (i Identity) PublicKey() string { return i.PrivateKey.PublicKey().String() }
// New generates a UUID and independent WireGuard key pair.
func New(nodeType model.NodeType, nodeName, serverURL string) (Identity, error) {
privateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return Identity{}, fmt.Errorf("generate Node WireGuard key: %w", err)
}
identity := Identity{
NodeID: uuid.NewString(), NodeType: nodeType, NodeName: strings.TrimSpace(nodeName),
PrivateKey: privateKey, ServerURL: strings.TrimRight(strings.TrimSpace(serverURL), "/"),
}
if err := identity.Validate(false); err != nil {
return Identity{}, err
}
return identity, nil
}
// Validate checks persistent identity invariants. requireToken is true after enrollment.
func (i Identity) Validate(requireToken bool) error {
if _, err := uuid.Parse(i.NodeID); err != nil {
return fmt.Errorf("Node ID must be a UUID: %w", err)
}
if !i.NodeType.Valid() {
return fmt.Errorf("invalid Node type %q", i.NodeType)
}
if i.NodeName == "" || len(i.NodeName) > 128 {
return errors.New("Node name must contain 1 to 128 bytes")
}
if err := config.ValidateServerURL(i.ServerURL); err != nil {
return fmt.Errorf("invalid Server URL: %w", err)
}
if i.PrivateKey == (wgtypes.Key{}) {
return errors.New("WireGuard private key must not be zero")
}
if requireToken && i.NodeToken == "" {
return errors.New("Node Token is required after registration")
}
return nil
}
// Store saves one role-specific identity JSON file.
type Store struct {
path string
protector Protector
}
func NewStore(path string, protector Protector) (*Store, error) {
if strings.TrimSpace(path) == "" || protector == nil {
return nil, errors.New("identity path and Protector are required")
}
return &Store{path: path, protector: protector}, nil
}
// Path returns the exact identity artifact location.
func (s *Store) Path() string { return s.path }
// LoadOwnedRoutes implements RouteManager's persistence boundary.
func (s *Store) LoadOwnedRoutes() ([]netip.Prefix, error) {
current, err := s.Load()
if err != nil {
return nil, err
}
routes := make([]netip.Prefix, 0, len(current.OwnedRoutes))
for _, raw := range current.OwnedRoutes {
prefix, err := netip.ParsePrefix(raw)
if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() {
return nil, fmt.Errorf("invalid owned Remote route %q", raw)
}
routes = append(routes, prefix)
}
return routes, nil
}
// SaveOwnedRoutes atomically updates RouteManager ownership metadata.
func (s *Store) SaveOwnedRoutes(routes []netip.Prefix) error {
current, err := s.Load()
if err != nil {
return err
}
current.OwnedRoutes = make([]string, 0, len(routes))
for _, prefix := range routes {
if !prefix.Addr().Is4() || prefix != prefix.Masked() {
return fmt.Errorf("owned Remote route must be canonical IPv4: %s", prefix)
}
current.OwnedRoutes = append(current.OwnedRoutes, prefix.String())
}
return s.Save(current)
}
// Load decrypts and validates a persisted identity.
func (s *Store) Load() (Identity, error) {
raw, err := os.ReadFile(s.path)
if err != nil {
return Identity{}, fmt.Errorf("read Node identity: %w", err)
}
var stored diskIdentity
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&stored); err != nil {
return Identity{}, fmt.Errorf("decode Node identity: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return Identity{}, errors.New("Node identity must contain one JSON object")
}
if stored.Version != diskVersion {
return Identity{}, fmt.Errorf("unsupported Node identity version %d", stored.Version)
}
privateKeyRaw, err := s.unprotect(stored.ProtectedPrivateKey)
if err != nil {
return Identity{}, fmt.Errorf("decrypt WireGuard private key: %w", err)
}
privateKey, err := wgtypes.ParseKey(string(privateKeyRaw))
clear(privateKeyRaw)
if err != nil {
return Identity{}, fmt.Errorf("parse decrypted WireGuard private key: %w", err)
}
nodeToken := ""
if stored.ProtectedNodeToken != "" {
nodeTokenRaw, err := s.unprotect(stored.ProtectedNodeToken)
if err != nil {
return Identity{}, fmt.Errorf("decrypt Node Token: %w", err)
}
nodeToken = string(nodeTokenRaw)
clear(nodeTokenRaw)
}
identity := Identity{
NodeID: stored.NodeID, NodeType: stored.NodeType, NodeName: stored.NodeName,
PrivateKey: privateKey, NodeToken: nodeToken, ServerURL: stored.ServerURL,
ConfigVersion: stored.ConfigVersion, OwnedRoutes: append([]string(nil), stored.OwnedRoutes...),
}
if err := identity.Validate(false); err != nil {
return Identity{}, fmt.Errorf("validate Node identity: %w", err)
}
return identity, nil
}
// Save protects all secrets and atomically replaces the identity artifact.
func (s *Store) Save(identity Identity) error {
if err := identity.Validate(false); err != nil {
return err
}
privateKeyRaw := []byte(identity.PrivateKey.String())
protectedPrivateKey, err := s.protect(privateKeyRaw)
clear(privateKeyRaw)
if err != nil {
return fmt.Errorf("encrypt WireGuard private key: %w", err)
}
protectedNodeToken := ""
if identity.NodeToken != "" {
protectedNodeToken, err = s.protect([]byte(identity.NodeToken))
if err != nil {
return fmt.Errorf("encrypt Node Token: %w", err)
}
}
stored := diskIdentity{
Version: diskVersion, NodeID: identity.NodeID, NodeType: identity.NodeType,
NodeName: identity.NodeName, ProtectedPrivateKey: protectedPrivateKey,
ProtectedNodeToken: protectedNodeToken, ServerURL: identity.ServerURL,
ConfigVersion: identity.ConfigVersion, OwnedRoutes: identity.OwnedRoutes,
}
encoded, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("encode Node identity: %w", err)
}
encoded = append(encoded, '\n')
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("create Node identity directory: %w", err)
}
temporary, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*.tmp")
if err != nil {
return fmt.Errorf("create temporary Node identity: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("restrict temporary Node identity: %w", err)
}
if _, err := temporary.Write(encoded); err != nil {
temporary.Close()
return fmt.Errorf("write temporary Node identity: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("flush temporary Node identity: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close temporary Node identity: %w", err)
}
if err := replaceFile(temporaryPath, s.path); err != nil {
return fmt.Errorf("replace Node identity: %w", err)
}
return nil
}
func (s *Store) protect(value []byte) (string, error) {
protected, err := s.protector.Protect(value)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(protected), nil
}
func (s *Store) unprotect(value string) ([]byte, error) {
protected, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return nil, err
}
return s.protector.Unprotect(protected)
}
type diskIdentity struct {
Version int `json:"version"`
NodeID string `json:"node_id"`
NodeType model.NodeType `json:"node_type"`
NodeName string `json:"node_name"`
ProtectedPrivateKey string `json:"protected_wg_private_key"`
ProtectedNodeToken string `json:"protected_node_token,omitempty"`
ServerURL string `json:"server_url"`
ConfigVersion uint64 `json:"config_version"`
OwnedRoutes []string `json:"owned_routes,omitempty"`
}
+84
View File
@@ -0,0 +1,84 @@
package identity
import (
"bytes"
"os"
"path/filepath"
"testing"
"remlink/internal/model"
)
type testProtector struct{}
func (testProtector) Protect(value []byte) ([]byte, error) {
result := append([]byte("protected:"), value...)
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
result[left], result[right] = result[right], result[left]
}
return result, nil
}
func (testProtector) Unprotect(value []byte) ([]byte, error) {
result := append([]byte(nil), value...)
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
result[left], result[right] = result[right], result[left]
}
return bytes.TrimPrefix(result, []byte("protected:")), nil
}
func TestIdentityRoundTripWithoutPlaintextSecrets(t *testing.T) {
path := filepath.Join(t.TempDir(), "Engineer", "identity.json")
store, err := NewStore(path, testProtector{})
if err != nil {
t.Fatal(err)
}
value, err := New(model.NodeTypeEngineer, "Engineer-A", "http://203.0.113.1:8080/")
if err != nil {
t.Fatal(err)
}
value.NodeToken = "plain-node-token-that-must-not-appear"
value.ConfigVersion = 7
value.OwnedRoutes = []string{"192.168.13.0/24"}
if err := store.Save(value); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte(value.NodeToken)) || bytes.Contains(raw, []byte(value.PrivateKey.String())) {
t.Fatalf("identity file contains a plaintext secret: %s", raw)
}
loaded, err := store.Load()
if err != nil {
t.Fatal(err)
}
if loaded.NodeID != value.NodeID || loaded.PrivateKey != value.PrivateKey || loaded.NodeToken != value.NodeToken ||
loaded.ConfigVersion != 7 || len(loaded.OwnedRoutes) != 1 {
t.Fatalf("loaded identity differs: %+v", loaded)
}
value.NodeName = "Engineer-Renamed"
if err := store.Save(value); err != nil {
t.Fatalf("replace identity: %v", err)
}
loaded, err = store.Load()
if err != nil || loaded.NodeName != value.NodeName {
t.Fatalf("replaced identity = %+v, %v", loaded, err)
}
}
func TestIdentityValidation(t *testing.T) {
if _, err := New(model.NodeType("server"), "x", "http://example.test"); err == nil {
t.Fatal("invalid Node type accepted")
}
if _, err := New(model.NodeTypeSite, "x", "not-a-url"); err == nil {
t.Fatal("invalid Server URL accepted")
}
for _, raw := range []string{"http://user:secret@example.test", "http://example.test/api", "http://example.test?redirect=elsewhere"} {
if _, err := New(model.NodeTypeSite, "x", raw); err == nil {
t.Errorf("unsafe Server URL %q accepted", raw)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package identity
import (
"errors"
"remlink/internal/model"
)
var ErrWindowsRequired = errors.New("default Node identity path requires Windows")
func DefaultPath(model.NodeType) (string, error) { return "", ErrWindowsRequired }
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package identity
import (
"errors"
"path/filepath"
"remlink/internal/appdir"
"remlink/internal/model"
)
// DefaultPath returns the portable identity path beside the role executable.
// Engineer and Site are shipped in different directories, so the common file
// name cannot collide across the two package bodies.
func DefaultPath(nodeType model.NodeType) (string, error) {
switch nodeType {
case model.NodeTypeEngineer, model.NodeTypeSite:
root, err := appdir.Executable()
if err != nil {
return "", err
}
return filepath.Join(root, "identity.json"), nil
default:
return "", errors.New("invalid Windows Node type")
}
}
+34
View File
@@ -0,0 +1,34 @@
//go:build windows
package identity
import (
"path/filepath"
"testing"
"remlink/internal/appdir"
"remlink/internal/model"
)
func TestDefaultPathUsesExecutableDirectoryForPortableRoles(t *testing.T) {
root, err := appdir.Executable()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "identity.json")
for _, role := range []model.NodeType{model.NodeTypeEngineer, model.NodeTypeSite} {
got, err := DefaultPath(role)
if err != nil {
t.Fatalf("DefaultPath(%s): %v", role, err)
}
if got != want {
t.Fatalf("DefaultPath(%s) = %q, want %q", role, got, want)
}
}
}
func TestDefaultPathRejectsUnknownRole(t *testing.T) {
if _, err := DefaultPath(model.NodeType("server")); err == nil {
t.Fatal("DefaultPath accepted a non-portable role")
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package identity
import "os"
func replaceFile(source, destination string) error { return os.Rename(source, destination) }
+18
View File
@@ -0,0 +1,18 @@
//go:build windows
package identity
import "golang.org/x/sys/windows"
func replaceFile(source, destination string) error {
sourcePointer, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationPointer, err := windows.UTF16PtrFromString(destination)
if err != nil {
return err
}
return windows.MoveFileEx(sourcePointer, destinationPointer,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
}