初版功能完成
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package identity
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceFile(source, destination string) error { return os.Rename(source, destination) }
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user