feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package attachmentstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/klarkxy/nekonest/relaycore"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
root string
|
||||
mu sync.RWMutex
|
||||
usedBytes int64
|
||||
fileCount int
|
||||
maxBytes int64
|
||||
maxFiles int
|
||||
}
|
||||
|
||||
func New(root string) (*Store, error) {
|
||||
return NewWithLimits(root, 1<<30, 10_000)
|
||||
}
|
||||
|
||||
func NewWithLimits(root string, maxBytes int64, maxFiles int) (*Store, error) {
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
if !filepath.IsAbs(root) {
|
||||
return nil, fmt.Errorf("attachment root must be absolute")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create attachment root: %w", err)
|
||||
}
|
||||
if err := os.Chmod(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("secure attachment root: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return nil, fmt.Errorf("attachment root must be a real directory")
|
||||
}
|
||||
if maxBytes <= 0 || maxFiles <= 0 {
|
||||
return nil, fmt.Errorf("attachment limits must be positive")
|
||||
}
|
||||
store := &Store{root: root, maxBytes: maxBytes, maxFiles: maxFiles}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("attachment store contains a symbolic link")
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".bin") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("inspect attachment payload %q", entry.Name())
|
||||
}
|
||||
store.usedBytes += info.Size()
|
||||
store.fileCount++
|
||||
}
|
||||
if store.usedBytes > maxBytes || store.fileCount > maxFiles {
|
||||
return nil, fmt.Errorf("attachment store already exceeds configured quota")
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func validID(id string) bool {
|
||||
if len(id) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range id {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Store) paths(id string) (string, string, error) {
|
||||
if !validID(id) {
|
||||
return "", "", fmt.Errorf("invalid attachment id")
|
||||
}
|
||||
payload := filepath.Join(s.root, id+".bin")
|
||||
metadata := filepath.Join(s.root, id+".json")
|
||||
if filepath.Dir(payload) != s.root || filepath.Dir(metadata) != s.root {
|
||||
return "", "", fmt.Errorf("attachment path escaped root")
|
||||
}
|
||||
return payload, metadata, nil
|
||||
}
|
||||
|
||||
func (s *Store) Put(ctx context.Context, attachment relaycore.Attachment, body []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if int64(len(body)) > s.maxBytes-s.usedBytes || s.fileCount >= s.maxFiles {
|
||||
return fmt.Errorf("attachment quota exceeded")
|
||||
}
|
||||
payloadPath, metadataPath, err := s.paths(attachment.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := json.Marshal(attachment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := os.OpenFile(payloadPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create attachment payload: %w", err)
|
||||
}
|
||||
removePayload := true
|
||||
defer func() {
|
||||
_ = payload.Close()
|
||||
if removePayload {
|
||||
_ = os.Remove(payloadPath)
|
||||
}
|
||||
}()
|
||||
if _, err := payload.Write(body); err != nil {
|
||||
return fmt.Errorf("write attachment payload: %w", err)
|
||||
}
|
||||
if err := payload.Sync(); err != nil {
|
||||
return fmt.Errorf("sync attachment payload: %w", err)
|
||||
}
|
||||
if err := payload.Close(); err != nil {
|
||||
return fmt.Errorf("close attachment payload: %w", err)
|
||||
}
|
||||
meta, err := os.OpenFile(metadataPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create attachment metadata: %w", err)
|
||||
}
|
||||
if _, err := meta.Write(metadata); err != nil {
|
||||
_ = meta.Close()
|
||||
_ = os.Remove(metadataPath)
|
||||
return fmt.Errorf("write attachment metadata: %w", err)
|
||||
}
|
||||
if err := meta.Sync(); err != nil {
|
||||
_ = meta.Close()
|
||||
_ = os.Remove(metadataPath)
|
||||
return fmt.Errorf("sync attachment metadata: %w", err)
|
||||
}
|
||||
if err := meta.Close(); err != nil {
|
||||
_ = os.Remove(metadataPath)
|
||||
return fmt.Errorf("close attachment metadata: %w", err)
|
||||
}
|
||||
removePayload = false
|
||||
s.usedBytes += int64(len(body))
|
||||
s.fileCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectLink(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("attachment artifact is not a regular file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id string) (relaycore.Attachment, io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return relaycore.Attachment{}, nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
payloadPath, metadataPath, err := s.paths(id)
|
||||
if err != nil {
|
||||
return relaycore.Attachment{}, nil, err
|
||||
}
|
||||
if err := rejectLink(metadataPath); err != nil {
|
||||
return relaycore.Attachment{}, nil, err
|
||||
}
|
||||
metadata, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
return relaycore.Attachment{}, nil, err
|
||||
}
|
||||
var attachment relaycore.Attachment
|
||||
if err := json.Unmarshal(metadata, &attachment); err != nil || attachment.ID != id {
|
||||
return relaycore.Attachment{}, nil, fmt.Errorf("invalid attachment metadata")
|
||||
}
|
||||
if err := rejectLink(payloadPath); err != nil {
|
||||
return relaycore.Attachment{}, nil, err
|
||||
}
|
||||
body, err := os.Open(payloadPath)
|
||||
if err != nil {
|
||||
return relaycore.Attachment{}, nil, err
|
||||
}
|
||||
return attachment, body, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(_ context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
payloadPath, metadataPath, err := s.paths(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var size int64
|
||||
existed := false
|
||||
if info, statErr := os.Lstat(payloadPath); statErr == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 {
|
||||
size = info.Size()
|
||||
existed = true
|
||||
}
|
||||
var joined error
|
||||
for _, path := range []string{metadataPath, payloadPath} {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
}
|
||||
if joined == nil && existed {
|
||||
s.usedBytes -= size
|
||||
if s.usedBytes < 0 {
|
||||
s.usedBytes = 0
|
||||
}
|
||||
if s.fileCount > 0 {
|
||||
s.fileCount--
|
||||
}
|
||||
}
|
||||
return joined
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package auditsink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/klarkxy/nekonest/relaycore"
|
||||
)
|
||||
|
||||
// Sink deliberately logs only event classification and error state. It never
|
||||
// serializes prompt, transcript, path, attachment, or approval payloads.
|
||||
type Sink struct{ Logger *slog.Logger }
|
||||
|
||||
func (sink Sink) Emit(_ context.Context, event relaycore.AuditEvent) {
|
||||
logger := sink.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
arguments := []any{"component", event.Component, "event", event.Name}
|
||||
if event.Err != nil {
|
||||
arguments = append(arguments, "error", event.Err.Error())
|
||||
}
|
||||
logger.Info("relay data-plane event", arguments...)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package authsnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SnapshotVersion = 1
|
||||
MaxTTL = 5 * time.Minute
|
||||
clockSkew = 30 * time.Second
|
||||
signatureDomain = "nekonest-cloud/relay-authorization-snapshot/v1\n"
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
CredentialHash string `json:"credential_hash"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
Ed25519Public string `json:"ed25519_public"`
|
||||
X25519Public string `json:"x25519_public"`
|
||||
Name string `json:"name"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
type Phone struct {
|
||||
PhoneID string `json:"phone_id"`
|
||||
CredentialHash string `json:"credential_hash"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
Ed25519Public string `json:"ed25519_public"`
|
||||
X25519Public string `json:"x25519_public"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Payload struct {
|
||||
SnapshotVersion int `json:"snapshot_version"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantStatus string `json:"tenant_status"`
|
||||
HomeRegion string `json:"home_region"`
|
||||
RelayNodeID string `json:"relay_node_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
AuthorizationRevision int64 `json:"authorization_revision"`
|
||||
Devices []Device `json:"devices"`
|
||||
Phones []Phone `json:"phones,omitempty"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Signed struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
KID string `json:"kid"`
|
||||
Payload Payload `json:"payload"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
NotBefore time.Time
|
||||
RetainUntil time.Time
|
||||
}
|
||||
|
||||
type Keyring map[string]Key
|
||||
|
||||
func parseTime(value string) (time.Time, error) {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid snapshot time: %w", err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validPrefixedHex(value, prefix string, hexLength int) bool {
|
||||
if !strings.HasPrefix(value, prefix) || len(value) != len(prefix)+hexLength {
|
||||
return false
|
||||
}
|
||||
for _, char := range strings.TrimPrefix(value, prefix) {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validDigest(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validPrefixedID(value, prefix string, maxSuffix int) bool {
|
||||
if !strings.HasPrefix(value, prefix) || len(value) <= len(prefix) || len(value) > len(prefix)+maxSuffix {
|
||||
return false
|
||||
}
|
||||
for _, char := range strings.TrimPrefix(value, prefix) {
|
||||
if !(char >= 'A' && char <= 'Z') && !(char >= 'a' && char <= 'z') &&
|
||||
!(char >= '0' && char <= '9') && !strings.ContainsRune("._:-", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validBase64URLKey(value string) bool {
|
||||
if len(value) != 43 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !(char >= 'A' && char <= 'Z') && !(char >= 'a' && char <= 'z') &&
|
||||
!(char >= '0' && char <= '9') && char != '_' && char != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validatePayload(payload Payload, now time.Time) (time.Time, time.Time, error) {
|
||||
if payload.SnapshotVersion != SnapshotVersion || !validPrefixedHex(payload.TenantID, "tenant_", 32) {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid snapshot identity")
|
||||
}
|
||||
if !strings.HasPrefix(payload.RelayNodeID, "node_") || len(payload.RelayNodeID) > 101 {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid relay node")
|
||||
}
|
||||
if payload.TenantStatus != "active" || payload.PlacementGeneration < 1 || payload.AuthorizationRevision < 0 {
|
||||
return time.Time{}, time.Time{}, errors.New("snapshot does not authorize an active placement")
|
||||
}
|
||||
issuedAt, err := parseTime(payload.IssuedAt)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
expiresAt, err := parseTime(payload.ExpiresAt)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if !expiresAt.After(issuedAt) || expiresAt.Sub(issuedAt) > MaxTTL {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid snapshot lifetime")
|
||||
}
|
||||
if now.Before(issuedAt.Add(-clockSkew)) || !now.Before(expiresAt) {
|
||||
return time.Time{}, time.Time{}, errors.New("snapshot is not current")
|
||||
}
|
||||
previous := ""
|
||||
for _, device := range payload.Devices {
|
||||
if !validPrefixedHex(device.DeviceID, "host_", 32) || !validDigest(device.CredentialHash) {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid snapshot device")
|
||||
}
|
||||
if strings.TrimSpace(device.Name) == "" || len(device.Name) > 48 ||
|
||||
(device.OS != "windows" && device.OS != "linux") ||
|
||||
!validBase64URLKey(device.Ed25519Public) || !validBase64URLKey(device.X25519Public) ||
|
||||
!validDigest(device.IdentityFingerprint) {
|
||||
return time.Time{}, time.Time{}, errors.New("incomplete snapshot device identity")
|
||||
}
|
||||
if previous != "" && device.DeviceID <= previous {
|
||||
return time.Time{}, time.Time{}, errors.New("snapshot devices are not strictly ordered")
|
||||
}
|
||||
previous = device.DeviceID
|
||||
}
|
||||
previous = ""
|
||||
for _, phone := range payload.Phones {
|
||||
if !validPrefixedID(phone.PhoneID, "phone_", 120) || !validDigest(phone.CredentialHash) ||
|
||||
strings.TrimSpace(phone.Name) == "" || len(phone.Name) > 48 ||
|
||||
!validBase64URLKey(phone.Ed25519Public) || !validBase64URLKey(phone.X25519Public) ||
|
||||
!validDigest(phone.IdentityFingerprint) {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid snapshot phone")
|
||||
}
|
||||
if previous != "" && phone.PhoneID <= previous {
|
||||
return time.Time{}, time.Time{}, errors.New("snapshot phones are not strictly ordered")
|
||||
}
|
||||
previous = phone.PhoneID
|
||||
}
|
||||
return issuedAt, expiresAt, nil
|
||||
}
|
||||
|
||||
func canonicalJSON(value any) ([]byte, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
||||
decoder.UseNumber()
|
||||
var decoded any
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := writeCanonical(&output, decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeCanonical(output *bytes.Buffer, value any) error {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
output.WriteString("null")
|
||||
case bool:
|
||||
output.WriteString(strconv.FormatBool(typed))
|
||||
case string:
|
||||
encoded, _ := json.Marshal(typed)
|
||||
output.Write(encoded)
|
||||
case json.Number:
|
||||
if _, err := strconv.ParseInt(string(typed), 10, 64); err != nil {
|
||||
return fmt.Errorf("snapshot contains unsupported number %q", typed)
|
||||
}
|
||||
output.WriteString(string(typed))
|
||||
case []any:
|
||||
output.WriteByte('[')
|
||||
for index, item := range typed {
|
||||
if index > 0 {
|
||||
output.WriteByte(',')
|
||||
}
|
||||
if err := writeCanonical(output, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
output.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
output.WriteByte('{')
|
||||
for index, key := range keys {
|
||||
if index > 0 {
|
||||
output.WriteByte(',')
|
||||
}
|
||||
encoded, _ := json.Marshal(key)
|
||||
output.Write(encoded)
|
||||
output.WriteByte(':')
|
||||
if err := writeCanonical(output, typed[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
output.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("unsupported snapshot JSON value %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SigningBytes(payload Payload) ([]byte, error) {
|
||||
canonical, err := canonicalJSON(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]byte(signatureDomain), canonical...), nil
|
||||
}
|
||||
|
||||
func Verify(snapshot Signed, keyring Keyring, now time.Time) (Payload, error) {
|
||||
if snapshot.Algorithm != "Ed25519" || snapshot.KID == "" {
|
||||
return Payload{}, errors.New("unsupported snapshot signature")
|
||||
}
|
||||
issuedAt, expiresAt, err := validatePayload(snapshot.Payload, now)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
key, ok := keyring[snapshot.KID]
|
||||
if !ok || len(key.PublicKey) != ed25519.PublicKeySize {
|
||||
return Payload{}, errors.New("snapshot signing key is not pinned")
|
||||
}
|
||||
if issuedAt.Before(key.NotBefore) || key.RetainUntil.Before(expiresAt) {
|
||||
return Payload{}, errors.New("snapshot signing key lifetime is insufficient")
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(snapshot.Signature)
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return Payload{}, errors.New("invalid snapshot signature encoding")
|
||||
}
|
||||
message, err := SigningBytes(snapshot.Payload)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
if !ed25519.Verify(key.PublicKey, message, signature) {
|
||||
return Payload{}, errors.New("snapshot signature mismatch")
|
||||
}
|
||||
return snapshot.Payload, nil
|
||||
}
|
||||
|
||||
func PublicKeyFromJWK(raw json.RawMessage) (ed25519.PublicKey, string, error) {
|
||||
var jwk struct {
|
||||
KTY string `json:"kty"`
|
||||
CRV string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
KID string `json:"kid"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &jwk); err != nil || jwk.KTY != "OKP" || jwk.CRV != "Ed25519" {
|
||||
return nil, "", errors.New("invalid Ed25519 JWK")
|
||||
}
|
||||
key, err := base64.RawURLEncoding.DecodeString(jwk.X)
|
||||
if err != nil || len(key) != ed25519.PublicKeySize {
|
||||
return nil, "", errors.New("invalid Ed25519 JWK key")
|
||||
}
|
||||
return ed25519.PublicKey(key), jwk.KID, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package authsnapshot
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func signedFixture(t *testing.T, now time.Time) (Signed, Keyring) {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedKey := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
payload := Payload{
|
||||
SnapshotVersion: 1,
|
||||
TenantID: "tenant_0123456789abcdef0123456789abcdef",
|
||||
TenantStatus: "active",
|
||||
HomeRegion: "cn-east",
|
||||
RelayNodeID: "node_east_1",
|
||||
PlacementGeneration: 2,
|
||||
AuthorizationRevision: 7,
|
||||
Devices: []Device{{
|
||||
DeviceID: "host_0123456789abcdef0123456789abcdef",
|
||||
CredentialHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
IdentityFingerprint: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
Ed25519Public: encodedKey,
|
||||
X25519Public: encodedKey,
|
||||
Name: "Laptop",
|
||||
OS: "windows",
|
||||
}},
|
||||
Phones: []Phone{{
|
||||
PhoneID: "phone_0123456789abcdef",
|
||||
CredentialHash: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
IdentityFingerprint: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
Ed25519Public: encodedKey,
|
||||
X25519Public: encodedKey,
|
||||
Name: "Phone",
|
||||
}},
|
||||
IssuedAt: now.Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(MaxTTL).Format(time.RFC3339Nano),
|
||||
}
|
||||
message, err := SigningBytes(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signed{
|
||||
Algorithm: "Ed25519",
|
||||
KID: "key-1",
|
||||
Payload: payload,
|
||||
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, message)),
|
||||
}, Keyring{"key-1": {PublicKey: public, NotBefore: now.Add(-time.Hour), RetainUntil: now.Add(MaxTTL)}}
|
||||
}
|
||||
|
||||
func TestVerifyAcceptsPinnedCurrentSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
|
||||
snapshot, keys := signedFixture(t, now)
|
||||
payload, err := Verify(snapshot, keys, now)
|
||||
if err != nil || payload.TenantID != snapshot.Payload.TenantID {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperingExpiryAndInsufficientKeyOverlap(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
|
||||
snapshot, keys := signedFixture(t, now)
|
||||
tampered := snapshot
|
||||
tampered.Payload.AuthorizationRevision++
|
||||
if _, err := Verify(tampered, keys, now); err == nil {
|
||||
t.Fatal("tampered snapshot accepted")
|
||||
}
|
||||
if _, err := Verify(snapshot, keys, now.Add(MaxTTL)); err == nil {
|
||||
t.Fatal("expired snapshot accepted")
|
||||
}
|
||||
key := keys["key-1"]
|
||||
key.RetainUntil = now.Add(MaxTTL - time.Second)
|
||||
keys["key-1"] = key
|
||||
if _, err := Verify(snapshot, keys, now); err == nil {
|
||||
t.Fatal("key without full TTL overlap accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddress string
|
||||
DataRoot string
|
||||
BackupRoot string
|
||||
NodeID string
|
||||
ControlPlaneURL string
|
||||
ClientCertificate string
|
||||
ClientKey string
|
||||
ControlPlaneCA string
|
||||
InternalRelayCA string
|
||||
InternalEndpoints map[string]string
|
||||
AllowedPWAOrigins []string
|
||||
RouteSecret []byte
|
||||
SourceHashSecret []byte
|
||||
ForwardSecret []byte
|
||||
HandoffSecret []byte
|
||||
SnapshotKeys authsnapshot.Keyring
|
||||
TrustedProxyRanges []netip.Prefix
|
||||
MaxTenants int
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func requiredEnv(name string) (string, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeSecret(name string) ([]byte, error) {
|
||||
raw, err := requiredEnv(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoded, decodeErr := base64.RawURLEncoding.DecodeString(raw)
|
||||
if decodeErr != nil {
|
||||
decoded, decodeErr = base64.StdEncoding.DecodeString(raw)
|
||||
}
|
||||
if decodeErr != nil || len(decoded) < 32 {
|
||||
return nil, fmt.Errorf("%s must be at least 32 random bytes encoded as base64url", name)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func exactOrigin(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("invalid origin")
|
||||
}
|
||||
loopback := parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1"
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
|
||||
return "", errors.New("origin must use HTTPS")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", errors.New("origin must not contain a path")
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host, nil
|
||||
}
|
||||
|
||||
func parseOrigins(raw string) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
var origins []string
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
if strings.TrimSpace(item) == "" {
|
||||
continue
|
||||
}
|
||||
origin, err := exactOrigin(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid PWA origin %q: %w", item, err)
|
||||
}
|
||||
if _, exists := seen[origin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return nil, errors.New("NEKONEST_RELAY_PWA_ORIGINS requires at least one exact origin")
|
||||
}
|
||||
return origins, nil
|
||||
}
|
||||
|
||||
type snapshotKeyConfig struct {
|
||||
KID string `json:"kid"`
|
||||
PublicKeyJWK json.RawMessage `json:"public_key_jwk"`
|
||||
NotBefore string `json:"not_before"`
|
||||
RetainUntil string `json:"retain_until"`
|
||||
}
|
||||
|
||||
func parseSnapshotKeys(raw string, now time.Time) (authsnapshot.Keyring, error) {
|
||||
var records []snapshotKeyConfig
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&records); err != nil || len(records) == 0 {
|
||||
return nil, errors.New("NEKONEST_RELAY_SNAPSHOT_KEYS must be a non-empty JSON array")
|
||||
}
|
||||
keyring := make(authsnapshot.Keyring, len(records))
|
||||
for _, record := range records {
|
||||
if record.KID == "" {
|
||||
return nil, errors.New("snapshot key kid is required")
|
||||
}
|
||||
key, jwkKID, err := authsnapshot.PublicKeyFromJWK(record.PublicKeyJWK)
|
||||
if err != nil || (jwkKID != "" && jwkKID != record.KID) {
|
||||
return nil, fmt.Errorf("invalid snapshot key %q", record.KID)
|
||||
}
|
||||
notBefore, err := time.Parse(time.RFC3339Nano, record.NotBefore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid not_before for snapshot key %q", record.KID)
|
||||
}
|
||||
retainUntil, err := time.Parse(time.RFC3339Nano, record.RetainUntil)
|
||||
if err != nil || !retainUntil.After(notBefore) {
|
||||
return nil, fmt.Errorf("invalid retain_until for snapshot key %q", record.KID)
|
||||
}
|
||||
if !retainUntil.After(now) {
|
||||
continue
|
||||
}
|
||||
keyring[record.KID] = authsnapshot.Key{
|
||||
PublicKey: ed25519.PublicKey(append([]byte(nil), key...)),
|
||||
NotBefore: notBefore,
|
||||
RetainUntil: retainUntil,
|
||||
}
|
||||
}
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("no unexpired snapshot verification key is configured")
|
||||
}
|
||||
return keyring, nil
|
||||
}
|
||||
|
||||
func parseProxyRanges(raw string) ([]netip.Prefix, error) {
|
||||
var prefixes []netip.Prefix
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trusted proxy range %q", item)
|
||||
}
|
||||
prefixes = append(prefixes, prefix.Masked())
|
||||
}
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
func parseInternalEndpoints(raw string) (map[string]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
var values map[string]string
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&values); err != nil || values == nil {
|
||||
return nil, errors.New("NEKONEST_RELAY_INTERNAL_ENDPOINTS must be a JSON object")
|
||||
}
|
||||
result := make(map[string]string, len(values))
|
||||
for reference, rawOrigin := range values {
|
||||
if !validEndpointReference(reference) {
|
||||
return nil, fmt.Errorf("invalid internal endpoint reference %q", reference)
|
||||
}
|
||||
origin, err := exactOrigin(rawOrigin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid internal endpoint %q: %w", reference, err)
|
||||
}
|
||||
result[reference] = origin
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validEndpointReference(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for index, char := range value {
|
||||
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') ||
|
||||
(char >= '0' && char <= '9') || (index > 0 && strings.ContainsRune("._:-", char)) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parsePositiveInt(name string, fallback int) (int, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return 0, fmt.Errorf("%s must be a positive integer", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
dataRoot, err := requiredEnv("NEKONEST_RELAY_DATA_ROOT")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
dataRoot, err = filepath.Abs(dataRoot)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve relay data root: %w", err)
|
||||
}
|
||||
backupRoot, err := requiredEnv("NEKONEST_RELAY_BACKUP_ROOT")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
backupRoot, err = filepath.Abs(backupRoot)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve relay backup root: %w", err)
|
||||
}
|
||||
for _, pair := range [][2]string{{dataRoot, backupRoot}, {backupRoot, dataRoot}} {
|
||||
relative, relErr := filepath.Rel(pair[0], pair[1])
|
||||
if relErr == nil && (relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)))) {
|
||||
return Config{}, errors.New("NEKONEST_RELAY_DATA_ROOT and NEKONEST_RELAY_BACKUP_ROOT must be separate directory trees")
|
||||
}
|
||||
}
|
||||
nodeID, err := requiredEnv("NEKONEST_RELAY_NODE_ID")
|
||||
if err != nil || !strings.HasPrefix(nodeID, "node_") {
|
||||
return Config{}, errors.New("NEKONEST_RELAY_NODE_ID must be a node_ identity")
|
||||
}
|
||||
controlPlaneURL, err := requiredEnv("NEKONEST_RELAY_CONTROL_PLANE_URL")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if _, err := exactOrigin(controlPlaneURL); err != nil {
|
||||
return Config{}, fmt.Errorf("invalid control plane URL: %w", err)
|
||||
}
|
||||
certificate, err := requiredEnv("NEKONEST_RELAY_MTLS_CERT_FILE")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
key, err := requiredEnv("NEKONEST_RELAY_MTLS_KEY_FILE")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
ca, err := requiredEnv("NEKONEST_RELAY_CONTROL_PLANE_CA_FILE")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
internalCA, err := requiredEnv("NEKONEST_RELAY_INTERNAL_CA_FILE")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
internalEndpoints, err := parseInternalEndpoints(os.Getenv("NEKONEST_RELAY_INTERNAL_ENDPOINTS"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
origins, err := parseOrigins(os.Getenv("NEKONEST_RELAY_PWA_ORIGINS"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
routeSecret, err := decodeSecret("NEKONEST_RELAY_ROUTE_SECRET")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
sourceSecret, err := decodeSecret("NEKONEST_RELAY_SOURCE_HASH_SECRET")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
forwardSecret, err := decodeSecret("NEKONEST_RELAY_FORWARD_SECRET")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
handoffSecret, err := decodeSecret("NEKONEST_RELAY_HANDOFF_SECRET")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
keyJSON, err := requiredEnv("NEKONEST_RELAY_SNAPSHOT_KEYS")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
keyring, err := parseSnapshotKeys(keyJSON, time.Now())
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
proxyRanges, err := parseProxyRanges(os.Getenv("NEKONEST_RELAY_TRUSTED_PROXY_CIDRS"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
maxTenants, err := parsePositiveInt("NEKONEST_RELAY_MAX_TENANTS", 256)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
listen := strings.TrimSpace(os.Getenv("NEKONEST_RELAY_LISTEN"))
|
||||
if listen == "" {
|
||||
listen = ":8080"
|
||||
}
|
||||
return Config{
|
||||
ListenAddress: listen,
|
||||
DataRoot: dataRoot,
|
||||
BackupRoot: backupRoot,
|
||||
NodeID: nodeID,
|
||||
ControlPlaneURL: controlPlaneURL,
|
||||
ClientCertificate: certificate,
|
||||
ClientKey: key,
|
||||
ControlPlaneCA: ca,
|
||||
InternalRelayCA: internalCA,
|
||||
InternalEndpoints: internalEndpoints,
|
||||
AllowedPWAOrigins: origins,
|
||||
RouteSecret: routeSecret,
|
||||
SourceHashSecret: sourceSecret,
|
||||
ForwardSecret: forwardSecret,
|
||||
HandoffSecret: handoffSecret,
|
||||
SnapshotKeys: keyring,
|
||||
TrustedProxyRanges: proxyRanges,
|
||||
MaxTenants: maxTenants,
|
||||
ShutdownTimeout: 15 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseOriginsRejectsPathsAndWildcards(t *testing.T) {
|
||||
if _, err := parseOrigins("https://pwa.example.cn,https://other.example.cn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range []string{"*", "https://pwa.example.cn/path", "http://pwa.example.cn"} {
|
||||
if _, err := parseOrigins(raw); err == nil {
|
||||
t.Fatalf("parseOrigins(%q) succeeded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapshotKeysPinsLifetime(t *testing.T) {
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
records := []snapshotKeyConfig{{
|
||||
KID: "key-1",
|
||||
PublicKeyJWK: json.RawMessage(`{"kty":"OKP","crv":"Ed25519","kid":"key-1","x":"` + base64.RawURLEncoding.EncodeToString(publicKey) + `"}`),
|
||||
NotBefore: now.Add(-time.Hour).Format(time.RFC3339Nano),
|
||||
RetainUntil: now.Add(time.Hour).Format(time.RFC3339Nano),
|
||||
}}
|
||||
raw, _ := json.Marshal(records)
|
||||
keys, err := parseSnapshotKeys(string(raw), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("keys = %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInternalEndpointsRequiresOpaqueReferencesAndOrigins(t *testing.T) {
|
||||
endpoints, err := parseInternalEndpoints(`{"cn-east-a":"https://relay-a.internal.example"}`)
|
||||
if err != nil || endpoints["cn-east-a"] != "https://relay-a.internal.example" {
|
||||
t.Fatalf("endpoints=%#v err=%v", endpoints, err)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"../escape":"https://relay.example"}`,
|
||||
`{"node":"https://relay.example/path"}`,
|
||||
`{"node":"http://relay.example"}`,
|
||||
} {
|
||||
if _, err := parseInternalEndpoints(raw); err == nil {
|
||||
t.Fatalf("accepted invalid endpoint map %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
const maxControlResponse = 1 << 20
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
ClientCertFile string
|
||||
ClientKeyFile string
|
||||
CAFile string
|
||||
Timeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
base *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type RemoteError struct {
|
||||
Status int
|
||||
Body protocol.ServiceErrorPayload
|
||||
}
|
||||
|
||||
func (e *RemoteError) Error() string {
|
||||
if e.Body.Message != "" {
|
||||
return fmt.Sprintf("control plane %s: %s", e.Body.ErrorCode, e.Body.Message)
|
||||
}
|
||||
return fmt.Sprintf("control plane HTTP %d", e.Status)
|
||||
}
|
||||
|
||||
func exactBase(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("invalid control-plane origin")
|
||||
}
|
||||
isLoopback := parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1"
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && isLoopback) {
|
||||
return nil, fmt.Errorf("control-plane origin must use HTTPS")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return nil, fmt.Errorf("control-plane URL must be an origin")
|
||||
}
|
||||
parsed.Path = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func New(config Config) (*Client, error) {
|
||||
base, err := exactBase(config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := config.HTTPClient
|
||||
if client == nil {
|
||||
certificate, err := tls.LoadX509KeyPair(config.ClientCertFile, config.ClientKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load relay mTLS identity: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(config.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load control-plane CA: %w", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("control-plane CA contains no certificates")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
RootCAs: roots,
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
}
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
}
|
||||
clone := *client
|
||||
clone.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &Client{base: base, http: &clone}, nil
|
||||
}
|
||||
|
||||
func (c *Client) endpoint(path string) string {
|
||||
return c.base.Scheme + "://" + c.base.Host + path
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, path string, request any, response any, headers http.Header) error {
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, maxControlResponse+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > maxControlResponse {
|
||||
return fmt.Errorf("control-plane response exceeds limit")
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
var envelope protocol.ServiceErrorPayload
|
||||
if err := json.Unmarshal(data, &envelope); err != nil || envelope.ErrorCode == "" {
|
||||
return &RemoteError{Status: res.StatusCode, Body: protocol.ServiceErrorPayload{
|
||||
ErrorCode: "route_unavailable", Message: "Control plane rejected the request", Retryable: false,
|
||||
}}
|
||||
}
|
||||
return &RemoteError{Status: res.StatusCode, Body: envelope}
|
||||
}
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(response); err != nil {
|
||||
return fmt.Errorf("decode control-plane response: %w", err)
|
||||
}
|
||||
if decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return fmt.Errorf("control-plane response contains trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SHA256Hex(value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
type SnapshotResponse struct {
|
||||
Snapshot authsnapshot.Signed `json:"snapshot"`
|
||||
PublicKeyJWK json.RawMessage `json:"public_key_jwk"`
|
||||
}
|
||||
|
||||
type RouteResolution struct {
|
||||
RelayNodeID string `json:"relay_node_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
HomeRegion string `json:"home_region"`
|
||||
Local bool `json:"local"`
|
||||
EndpointRef string `json:"endpoint_ref,omitempty"`
|
||||
}
|
||||
|
||||
func (route RouteResolution) Validate() error {
|
||||
if route.RelayNodeID == "" || route.PlacementGeneration < 1 || route.HomeRegion == "" {
|
||||
return errors.New("control plane returned an incomplete route")
|
||||
}
|
||||
if route.Local && route.EndpointRef != "" {
|
||||
return errors.New("local route unexpectedly contains an endpoint reference")
|
||||
}
|
||||
if !route.Local && route.EndpointRef == "" {
|
||||
return errors.New("remote route contains no endpoint reference")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ResolveDeviceRoute(ctx context.Context, deviceID, tokenHash string) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-device-route", map[string]string{
|
||||
"device_id": deviceID, "token_hash": tokenHash,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) ResolvePhoneRoute(ctx context.Context, routeHandle, phoneTokenHash string) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-phone-route", map[string]string{
|
||||
"route_handle": routeHandle, "phone_token_hash": phoneTokenHash,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) ResolveTenantRoute(ctx context.Context, tenantID string, generation int64) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-tenant-route", map[string]any{
|
||||
"tenant_id": tenantID, "placement_generation": generation,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) ResolveHandoffRoute(ctx context.Context, ticket, pwaOrigin string) (RouteResolution, error) {
|
||||
var response RouteResolution
|
||||
err := c.doJSON(ctx, "/api/internal/relay/resolve-handoff-route", map[string]string{
|
||||
"ticket": ticket, "pwa_origin": pwaOrigin,
|
||||
}, &response, nil)
|
||||
if err == nil {
|
||||
err = response.Validate()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID, tokenHash string) (SnapshotResponse, error) {
|
||||
var response SnapshotResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorize-device", map[string]string{
|
||||
"device_id": deviceID, "token_hash": tokenHash,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizationSnapshot(ctx context.Context, tenantID string, generation int64) (SnapshotResponse, error) {
|
||||
var response SnapshotResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorization-snapshot", map[string]any{
|
||||
"tenant_id": tenantID, "placement_generation": generation,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
type DeltaResponse struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
AuthorizationRevision int64 `json:"authorization_revision"`
|
||||
TenantStatus string `json:"tenant_status"`
|
||||
Changed bool `json:"changed"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizationDelta(ctx context.Context, tenantID string, afterRevision int64) (DeltaResponse, error) {
|
||||
var response DeltaResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorization-delta", map[string]any{
|
||||
"tenant_id": tenantID, "after_revision": afterRevision,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
type MigrationAssignment struct {
|
||||
MigrationID string `json:"migration_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Role string `json:"role"`
|
||||
SourceNodeID string `json:"source_node_id"`
|
||||
TargetNodeID string `json:"target_node_id"`
|
||||
SourceGeneration int64 `json:"source_generation"`
|
||||
TargetGeneration int64 `json:"target_generation"`
|
||||
State string `json:"state"`
|
||||
BackupRef string `json:"backup_ref,omitempty"`
|
||||
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
|
||||
FinalizeAfter string `json:"finalize_after,omitempty"`
|
||||
}
|
||||
|
||||
type PurgeAssignment struct {
|
||||
PurgeID string `json:"purge_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
}
|
||||
|
||||
type HeartbeatAssignments struct {
|
||||
Migrations []MigrationAssignment
|
||||
Purges []PurgeAssignment
|
||||
}
|
||||
|
||||
func (c *Client) Heartbeat(ctx context.Context, generation int64, capacityTenants int) (HeartbeatAssignments, error) {
|
||||
var response struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Migrations []MigrationAssignment `json:"migrations"`
|
||||
Purges []PurgeAssignment `json:"purges"`
|
||||
}
|
||||
err := c.doJSON(ctx, "/api/internal/relay/heartbeat", map[string]any{
|
||||
"generation": generation, "capacity_tenants": capacityTenants,
|
||||
}, &response, nil)
|
||||
if err == nil && !response.Accepted {
|
||||
return HeartbeatAssignments{}, errors.New("control plane did not accept relay heartbeat")
|
||||
}
|
||||
return HeartbeatAssignments{Migrations: response.Migrations, Purges: response.Purges}, err
|
||||
}
|
||||
|
||||
type MigrationAdvance struct {
|
||||
MigrationID string `json:"migration_id"`
|
||||
Action string `json:"action"`
|
||||
BackupRef string `json:"backup_ref,omitempty"`
|
||||
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) AdvanceMigration(ctx context.Context, input MigrationAdvance) error {
|
||||
var response struct {
|
||||
MigrationID string `json:"migration_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
return c.doJSON(ctx, "/api/internal/relay/migrations/advance", input, &response, nil)
|
||||
}
|
||||
|
||||
type PurgeAdvance struct {
|
||||
PurgeID string `json:"purge_id"`
|
||||
Action string `json:"action"`
|
||||
EvidenceSHA256 string `json:"evidence_sha256,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) AdvancePurge(ctx context.Context, input PurgeAdvance) error {
|
||||
var response struct {
|
||||
PurgeID string `json:"purge_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
return c.doJSON(ctx, "/api/internal/relay/purges/advance", input, &response, nil)
|
||||
}
|
||||
|
||||
type PhoneAuthorization struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
HomeRegion string `json:"home_region"`
|
||||
RelayNodeID string `json:"relay_node_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
Phone struct {
|
||||
PhoneID string `json:"phone_id"`
|
||||
Name string `json:"name"`
|
||||
Ed25519Public string `json:"ed25519_public"`
|
||||
X25519Public string `json:"x25519_public"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
} `json:"phone"`
|
||||
}
|
||||
|
||||
func (c *Client) AuthorizePhone(ctx context.Context, routeHandle, phoneTokenHash string) (PhoneAuthorization, error) {
|
||||
var response PhoneAuthorization
|
||||
err := c.doJSON(ctx, "/api/internal/relay/authorize-phone", map[string]string{
|
||||
"route_handle": routeHandle, "phone_token_hash": phoneTokenHash,
|
||||
}, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
type ConsumedHandoff struct {
|
||||
HandoffID string `json:"handoff_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
PhoneEd25519Public string `json:"phone_ed25519_public"`
|
||||
PhoneX25519Public string `json:"phone_x25519_public"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
}
|
||||
|
||||
func (c *Client) ConsumePhoneHandoff(ctx context.Context, request any) (ConsumedHandoff, error) {
|
||||
var response ConsumedHandoff
|
||||
err := c.doJSON(ctx, "/api/internal/relay/consume-phone-handoff", request, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (c *Client) CompletePhoneHandoff(ctx context.Context, handoffID, phoneID, phoneTokenHash, routeHandleHash string) error {
|
||||
var response struct {
|
||||
Completed bool `json:"completed"`
|
||||
}
|
||||
err := c.doJSON(ctx, "/api/internal/relay/complete-phone-handoff", map[string]string{
|
||||
"handoff_id": handoffID,
|
||||
"phone_id": phoneID,
|
||||
"phone_token_hash": phoneTokenHash,
|
||||
"route_handle_hash": routeHandleHash,
|
||||
}, &response, nil)
|
||||
if err == nil && !response.Completed {
|
||||
return errors.New("control plane did not complete phone handoff")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) RevokePhone(ctx context.Context, tenantID, phoneID, reason string) error {
|
||||
var response struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
err := c.doJSON(ctx, "/api/internal/relay/revoke-phone", map[string]string{
|
||||
"tenant_id": tenantID, "phone_id": phoneID, "reason": reason,
|
||||
}, &response, nil)
|
||||
if err == nil && !response.Revoked {
|
||||
return errors.New("control plane did not revoke phone")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) RegisterDevice(ctx context.Context, bootstrap, sourceHash string, request json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(request, &decoded); err != nil {
|
||||
return protocol.DeviceRegistrationResponse{}, errors.New("invalid registration JSON")
|
||||
}
|
||||
decoded["bootstrap_token"] = bootstrap
|
||||
decoded["source_hash"] = sourceHash
|
||||
var response protocol.DeviceRegistrationResponse
|
||||
err := c.doJSON(ctx, "/api/internal/relay/register-device", decoded, &response, nil)
|
||||
return response, err
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
headerVersion = "X-Neko-Relay-Forwarded"
|
||||
headerSource = "X-Neko-Relay-Source"
|
||||
headerTarget = "X-Neko-Relay-Target"
|
||||
headerTimestamp = "X-Neko-Relay-Timestamp"
|
||||
headerSignature = "X-Neko-Relay-Signature"
|
||||
maxHTTPResponse = 16 << 20
|
||||
maxWSMessage = 4 << 20
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
NodeID string
|
||||
Endpoints map[string]string
|
||||
Secret []byte
|
||||
ClientCertFile string
|
||||
ClientKeyFile string
|
||||
CAFile string
|
||||
HTTPClient *http.Client
|
||||
WebSocketDialer *websocket.Dialer
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Forwarder struct {
|
||||
nodeID string
|
||||
endpoints map[string]*url.URL
|
||||
secret []byte
|
||||
http *http.Client
|
||||
websocket *websocket.Dialer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func exactOrigin(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("invalid internal Relay origin")
|
||||
}
|
||||
loopback := parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1"
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
|
||||
return nil, errors.New("internal Relay origin must use HTTPS")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return nil, errors.New("internal Relay origin must not contain a path")
|
||||
}
|
||||
parsed.Path = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func tlsConfig(certFile, keyFile, caFile string) (*tls.Config, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load internal Relay mTLS identity: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load internal Relay CA: %w", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("internal Relay CA contains no certificates")
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, RootCAs: roots,
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func New(config Config) (*Forwarder, error) {
|
||||
if strings.TrimSpace(config.NodeID) == "" || len(config.Secret) < 32 {
|
||||
return nil, errors.New("forwarder requires node identity and a 32-byte secret")
|
||||
}
|
||||
endpoints := make(map[string]*url.URL, len(config.Endpoints))
|
||||
for reference, raw := range config.Endpoints {
|
||||
if reference == "" {
|
||||
return nil, errors.New("internal endpoint reference is empty")
|
||||
}
|
||||
origin, err := exactOrigin(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("internal endpoint %q: %w", reference, err)
|
||||
}
|
||||
endpoints[reference] = origin
|
||||
}
|
||||
client := config.HTTPClient
|
||||
dialer := config.WebSocketDialer
|
||||
if client == nil || dialer == nil {
|
||||
tlsConfiguration, err := tlsConfig(config.ClientCertFile, config.ClientKeyFile, config.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.TLSClientConfig = tlsConfiguration.Clone()
|
||||
client = &http.Client{Transport: transport, Timeout: 75 * time.Second}
|
||||
}
|
||||
if dialer == nil {
|
||||
dialer = &websocket.Dialer{
|
||||
TLSClientConfig: tlsConfiguration.Clone(), HandshakeTimeout: 10 * time.Second,
|
||||
ReadBufferSize: 4096, WriteBufferSize: 4096, EnableCompression: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
clone := *client
|
||||
clone.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if config.Now == nil {
|
||||
config.Now = time.Now
|
||||
}
|
||||
return &Forwarder{
|
||||
nodeID: strings.TrimSpace(config.NodeID), endpoints: endpoints,
|
||||
secret: append([]byte(nil), config.Secret...), http: &clone,
|
||||
websocket: dialer, now: config.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requestTarget(origin *url.URL, request *http.Request) *url.URL {
|
||||
target := *origin
|
||||
target.Path = request.URL.Path
|
||||
target.RawPath = request.URL.RawPath
|
||||
target.RawQuery = request.URL.RawQuery
|
||||
return &target
|
||||
}
|
||||
|
||||
func transcript(method, requestURI, source, target, timestamp string) string {
|
||||
return "nekonest-cloud/internal-forward/v1\x00" + strings.ToUpper(method) + "\x00" +
|
||||
requestURI + "\x00" + source + "\x00" + target + "\x00" + timestamp
|
||||
}
|
||||
|
||||
func signature(secret []byte, method, requestURI, source, target, timestamp string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(transcript(method, requestURI, source, target, timestamp)))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func stripForwardHeaders(header http.Header) {
|
||||
for _, name := range []string{headerVersion, headerSource, headerTarget, headerTimestamp, headerSignature} {
|
||||
header.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (forwarder *Forwarder) sign(header http.Header, method, requestURI, targetNodeID string) {
|
||||
stripForwardHeaders(header)
|
||||
timestamp := strconv.FormatInt(forwarder.now().Unix(), 10)
|
||||
header.Set(headerVersion, "v1")
|
||||
header.Set(headerSource, forwarder.nodeID)
|
||||
header.Set(headerTarget, targetNodeID)
|
||||
header.Set(headerTimestamp, timestamp)
|
||||
header.Set(headerSignature, signature(
|
||||
forwarder.secret, method, requestURI, forwarder.nodeID, targetNodeID, timestamp,
|
||||
))
|
||||
}
|
||||
|
||||
func VerifyIncoming(request *http.Request, targetNodeID string, secret []byte, now time.Time) error {
|
||||
values := []string{
|
||||
request.Header.Get(headerVersion), request.Header.Get(headerSource),
|
||||
request.Header.Get(headerTarget), request.Header.Get(headerTimestamp),
|
||||
request.Header.Get(headerSignature),
|
||||
}
|
||||
present := false
|
||||
for _, value := range values {
|
||||
present = present || strings.TrimSpace(value) != ""
|
||||
}
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
if values[0] != "v1" || values[1] == "" || values[2] != targetNodeID || len(secret) < 32 {
|
||||
return errors.New("invalid internal Relay forwarding identity")
|
||||
}
|
||||
timestamp, err := strconv.ParseInt(values[3], 10, 64)
|
||||
if err != nil || timestamp < now.Unix()-30 || timestamp > now.Unix()+30 {
|
||||
return errors.New("internal Relay forwarding assertion expired")
|
||||
}
|
||||
expected := signature(secret, request.Method, request.URL.RequestURI(), values[1], values[2], values[3])
|
||||
if len(values[4]) != len(expected) || subtle.ConstantTimeCompare([]byte(values[4]), []byte(expected)) != 1 {
|
||||
return errors.New("invalid internal Relay forwarding signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (forwarder *Forwarder) endpoint(reference string) (*url.URL, error) {
|
||||
origin := forwarder.endpoints[reference]
|
||||
if origin == nil {
|
||||
return nil, errors.New("internal Relay endpoint reference is not configured")
|
||||
}
|
||||
clone := *origin
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
var hopHeaders = []string{
|
||||
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate",
|
||||
"Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
|
||||
}
|
||||
|
||||
func stripHopHeaders(header http.Header) {
|
||||
for _, value := range header.Values("Connection") {
|
||||
for _, token := range strings.Split(value, ",") {
|
||||
header.Del(strings.TrimSpace(token))
|
||||
}
|
||||
}
|
||||
for _, name := range hopHeaders {
|
||||
header.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (forwarder *Forwarder) ForwardHTTP(w http.ResponseWriter, request *http.Request, endpointRef, targetNodeID string) error {
|
||||
origin, err := forwarder.endpoint(endpointRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := requestTarget(origin, request)
|
||||
forwarded := request.Clone(request.Context())
|
||||
forwarded.URL = target
|
||||
forwarded.Host = target.Host
|
||||
forwarded.RequestURI = ""
|
||||
forwarded.Header = request.Header.Clone()
|
||||
stripHopHeaders(forwarded.Header)
|
||||
forwarder.sign(forwarded.Header, forwarded.Method, target.RequestURI(), targetNodeID)
|
||||
response, err := forwarder.http.Do(forwarded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 300 && response.StatusCode < 400 {
|
||||
return errors.New("internal Relay attempted to redirect a client")
|
||||
}
|
||||
if response.ContentLength > maxHTTPResponse {
|
||||
return errors.New("internal Relay response exceeds limit")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxHTTPResponse+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) > maxHTTPResponse {
|
||||
return errors.New("internal Relay response exceeds limit")
|
||||
}
|
||||
for key, values := range response.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
stripHopHeaders(w.Header())
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (forwarder *Forwarder) DialWebSocket(
|
||||
ctx context.Context, request *http.Request, endpointRef, targetNodeID string,
|
||||
) (*websocket.Conn, *http.Response, error) {
|
||||
origin, err := forwarder.endpoint(endpointRef)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
target := requestTarget(origin, request)
|
||||
if target.Scheme == "https" {
|
||||
target.Scheme = "wss"
|
||||
} else {
|
||||
target.Scheme = "ws"
|
||||
}
|
||||
header := request.Header.Clone()
|
||||
stripHopHeaders(header)
|
||||
header.Del("Sec-WebSocket-Key")
|
||||
header.Del("Sec-WebSocket-Version")
|
||||
header.Del("Sec-WebSocket-Extensions")
|
||||
header.Del("Sec-WebSocket-Protocol")
|
||||
forwarder.sign(header, request.Method, target.RequestURI(), targetNodeID)
|
||||
return forwarder.websocket.DialContext(ctx, target.String(), header)
|
||||
}
|
||||
|
||||
func Tunnel(ctx context.Context, client, target *websocket.Conn, firstType int, firstFrame []byte) error {
|
||||
client.SetReadLimit(maxWSMessage)
|
||||
target.SetReadLimit(maxWSMessage)
|
||||
if err := target.WriteMessage(firstType, firstFrame); err != nil {
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 2)
|
||||
pump := func(destination, source *websocket.Conn) {
|
||||
for {
|
||||
messageType, message, err := source.ReadMessage()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
if err := destination.WriteMessage(messageType, message); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
go pump(target, client)
|
||||
go pump(client, target)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = client.Close()
|
||||
_ = target.Close()
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
_ = client.Close()
|
||||
_ = target.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestForwardAssertionBindsMethodPathAndTarget(t *testing.T) {
|
||||
secret := []byte("0123456789abcdef0123456789abcdef")
|
||||
now := time.Unix(1_786_536_000, 0)
|
||||
forwarder, err := New(Config{
|
||||
NodeID: "node_source", Secret: secret,
|
||||
Endpoints: map[string]string{}, HTTPClient: http.DefaultClient,
|
||||
WebSocketDialer: nil, Now: func() time.Time { return now },
|
||||
ClientCertFile: "missing", ClientKeyFile: "missing", CAFile: "missing",
|
||||
})
|
||||
if err == nil {
|
||||
// A nil WebSocket dialer intentionally requires real mTLS files.
|
||||
t.Fatal("forwarder accepted a partial injected transport")
|
||||
}
|
||||
forwarder = &Forwarder{nodeID: "node_source", secret: secret, now: func() time.Time { return now }}
|
||||
request := httptest.NewRequest(http.MethodPost, "https://relay.example/ws/daemon?generation=2", nil)
|
||||
forwarder.sign(request.Header, request.Method, request.URL.RequestURI(), "node_target")
|
||||
if err := VerifyIncoming(request, "node_target", secret, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.URL.Path = "/ws/phone"
|
||||
if err := VerifyIncoming(request, "node_target", secret, now); err == nil {
|
||||
t.Fatal("forward assertion survived path substitution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointReferenceCannotSelectArbitraryURL(t *testing.T) {
|
||||
forwarder := &Forwarder{endpoints: map[string]*url.URL{}}
|
||||
if _, err := forwarder.endpoint("https://evil.example"); err == nil {
|
||||
t.Fatal("unconfigured URL was accepted as an endpoint reference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantbackup"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
)
|
||||
|
||||
type Registry interface {
|
||||
Quiesce(tenantID string, generation int64) (tenantfs.Paths, error)
|
||||
}
|
||||
|
||||
type ControlPlane interface {
|
||||
AdvanceMigration(context.Context, controlplane.MigrationAdvance) error
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DataRoot string
|
||||
BackupRoot string
|
||||
Registry Registry
|
||||
ControlPlane ControlPlane
|
||||
MaxConcurrent int
|
||||
Now func() time.Time
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
config Config
|
||||
mu sync.Mutex
|
||||
inFlight map[string]struct{}
|
||||
semaphore chan struct{}
|
||||
wait sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(config Config) (*Manager, error) {
|
||||
if config.DataRoot == "" || config.BackupRoot == "" || config.Registry == nil || config.ControlPlane == nil {
|
||||
return nil, errors.New("migration manager requires data, backup, registry, and control-plane ports")
|
||||
}
|
||||
if config.MaxConcurrent <= 0 {
|
||||
config.MaxConcurrent = 2
|
||||
}
|
||||
if config.MaxConcurrent > 8 {
|
||||
return nil, errors.New("migration concurrency is unreasonably high")
|
||||
}
|
||||
if config.Now == nil {
|
||||
config.Now = time.Now
|
||||
}
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
return &Manager{
|
||||
config: config, inFlight: make(map[string]struct{}),
|
||||
semaphore: make(chan struct{}, config.MaxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle starts bounded, de-duplicated migration work returned by a trusted
|
||||
// heartbeat. A later heartbeat safely retries any stage that did not commit.
|
||||
func (manager *Manager) Handle(ctx context.Context, assignments []controlplane.MigrationAssignment) {
|
||||
for _, assignment := range assignments {
|
||||
manager.mu.Lock()
|
||||
if _, exists := manager.inFlight[assignment.MigrationID]; exists {
|
||||
manager.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case manager.semaphore <- struct{}{}:
|
||||
manager.inFlight[assignment.MigrationID] = struct{}{}
|
||||
manager.wait.Add(1)
|
||||
manager.mu.Unlock()
|
||||
go func(assignment controlplane.MigrationAssignment) {
|
||||
defer func() {
|
||||
<-manager.semaphore
|
||||
manager.mu.Lock()
|
||||
delete(manager.inFlight, assignment.MigrationID)
|
||||
manager.mu.Unlock()
|
||||
manager.wait.Done()
|
||||
}()
|
||||
if err := manager.process(ctx, assignment); err != nil && ctx.Err() == nil {
|
||||
manager.config.Logger.Error("Relay migration stage failed",
|
||||
"migration_id", assignment.MigrationID, "tenant_id", assignment.TenantID,
|
||||
"state", assignment.State, "error", err)
|
||||
}
|
||||
}(assignment)
|
||||
default:
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) process(ctx context.Context, assignment controlplane.MigrationAssignment) error {
|
||||
if assignment.MigrationID == "" || assignment.TenantID == "" {
|
||||
return errors.New("migration assignment is incomplete")
|
||||
}
|
||||
switch assignment.State {
|
||||
case "quiescing":
|
||||
if assignment.Role != "source" {
|
||||
return errors.New("quiescing assignment did not target the source")
|
||||
}
|
||||
if _, err := manager.config.Registry.Quiesce(assignment.TenantID, assignment.SourceGeneration); err != nil {
|
||||
return manager.fail(ctx, assignment, "relay_quiesce_failed", err)
|
||||
}
|
||||
backup, err := tenantbackup.Create(
|
||||
ctx, manager.config.DataRoot, manager.config.BackupRoot,
|
||||
assignment.TenantID, assignment.SourceGeneration, manager.config.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return manager.fail(ctx, assignment, "relay_backup_failed", err)
|
||||
}
|
||||
return manager.config.ControlPlane.AdvanceMigration(ctx, controlplane.MigrationAdvance{
|
||||
MigrationID: assignment.MigrationID, Action: "quiesced",
|
||||
BackupRef: backup.BackupRef, ManifestSHA256: backup.ManifestSHA256,
|
||||
})
|
||||
case "copying":
|
||||
if assignment.Role != "target" || assignment.BackupRef == "" || assignment.ManifestSHA256 == "" {
|
||||
return errors.New("copying assignment is incomplete")
|
||||
}
|
||||
backupPath, err := tenantbackup.ResolveReference(manager.config.BackupRoot, assignment.BackupRef)
|
||||
if err != nil {
|
||||
return manager.fail(ctx, assignment, "relay_backup_unavailable", err)
|
||||
}
|
||||
if _, err := tenantbackup.Restore(
|
||||
ctx, backupPath, manager.config.DataRoot, assignment.TenantID,
|
||||
assignment.SourceGeneration, assignment.ManifestSHA256,
|
||||
); err != nil {
|
||||
return manager.fail(ctx, assignment, "relay_restore_failed", err)
|
||||
}
|
||||
return manager.config.ControlPlane.AdvanceMigration(ctx, controlplane.MigrationAdvance{
|
||||
MigrationID: assignment.MigrationID, Action: "copied",
|
||||
BackupRef: assignment.BackupRef, ManifestSHA256: assignment.ManifestSHA256,
|
||||
})
|
||||
case "switching":
|
||||
if assignment.Role != "target" {
|
||||
return errors.New("switching assignment did not target the destination")
|
||||
}
|
||||
return manager.config.ControlPlane.AdvanceMigration(ctx, controlplane.MigrationAdvance{
|
||||
MigrationID: assignment.MigrationID, Action: "switched",
|
||||
})
|
||||
case "draining":
|
||||
if assignment.Role != "target" || assignment.FinalizeAfter == "" {
|
||||
return errors.New("draining assignment is incomplete")
|
||||
}
|
||||
finalizeAfter, err := time.Parse(time.RFC3339Nano, assignment.FinalizeAfter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid finalize fence: %w", err)
|
||||
}
|
||||
if manager.config.Now().Before(finalizeAfter) {
|
||||
return nil
|
||||
}
|
||||
return manager.config.ControlPlane.AdvanceMigration(ctx, controlplane.MigrationAdvance{
|
||||
MigrationID: assignment.MigrationID, Action: "finalized",
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unsupported migration state %q", assignment.State)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) fail(ctx context.Context, assignment controlplane.MigrationAssignment, code string, cause error) error {
|
||||
advanceErr := manager.config.ControlPlane.AdvanceMigration(ctx, controlplane.MigrationAdvance{
|
||||
MigrationID: assignment.MigrationID, Action: "failed", ErrorCode: code,
|
||||
})
|
||||
if advanceErr != nil {
|
||||
return errors.Join(cause, fmt.Errorf("report migration failure: %w", advanceErr))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (manager *Manager) Wait() { manager.wait.Wait() }
|
||||
@@ -0,0 +1,103 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantbackup"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantstore"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
type fakeRegistry struct {
|
||||
paths tenantfs.Paths
|
||||
err error
|
||||
}
|
||||
|
||||
func (registry fakeRegistry) Quiesce(string, int64) (tenantfs.Paths, error) {
|
||||
return registry.paths, registry.err
|
||||
}
|
||||
|
||||
type fakeControl struct {
|
||||
updates []controlplane.MigrationAdvance
|
||||
}
|
||||
|
||||
func (control *fakeControl) AdvanceMigration(_ context.Context, update controlplane.MigrationAdvance) error {
|
||||
control.updates = append(control.updates, update)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSource(t *testing.T, root, tenantID string) tenantfs.Paths {
|
||||
t.Helper()
|
||||
paths, err := tenantfs.Resolve(root, tenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := tenantstore.NewWithTransportMode(paths.Database, string(protocol.TransportSealed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.RegisterDevice("host_0123456789abcdef0123456789abcdef", "Host", "linux"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func TestManagerCreatesFencedBackupAndRestoresTarget(t *testing.T) {
|
||||
const tenantID = "tenant_0123456789abcdef0123456789abcdef"
|
||||
sourceRoot := t.TempDir()
|
||||
paths := createSource(t, sourceRoot, tenantID)
|
||||
backupRoot := t.TempDir()
|
||||
control := &fakeControl{}
|
||||
now := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
|
||||
source, err := New(Config{
|
||||
DataRoot: sourceRoot, BackupRoot: backupRoot,
|
||||
Registry: fakeRegistry{paths: paths}, ControlPlane: control, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assignment := controlplane.MigrationAssignment{
|
||||
MigrationID: "migration_0123456789abcdef0123456789abcdef", TenantID: tenantID,
|
||||
Role: "source", State: "quiescing", SourceGeneration: 3, TargetGeneration: 4,
|
||||
}
|
||||
if err := source.process(context.Background(), assignment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(control.updates) != 1 || control.updates[0].Action != "quiesced" || control.updates[0].BackupRef == "" {
|
||||
t.Fatalf("source update = %#v", control.updates)
|
||||
}
|
||||
backupPath, err := tenantbackup.ResolveReference(backupRoot, control.updates[0].BackupRef)
|
||||
if err != nil || filepath.Base(backupPath) == "" {
|
||||
t.Fatalf("backup path = %q err=%v", backupPath, err)
|
||||
}
|
||||
targetRoot := t.TempDir()
|
||||
targetControl := &fakeControl{}
|
||||
target, err := New(Config{
|
||||
DataRoot: targetRoot, BackupRoot: backupRoot,
|
||||
Registry: fakeRegistry{}, ControlPlane: targetControl, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assignment.Role = "target"
|
||||
assignment.State = "copying"
|
||||
assignment.BackupRef = control.updates[0].BackupRef
|
||||
assignment.ManifestSHA256 = control.updates[0].ManifestSHA256
|
||||
if err := target.process(context.Background(), assignment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.process(context.Background(), assignment); err != nil {
|
||||
t.Fatal("copy retry was not idempotent:", err)
|
||||
}
|
||||
if len(targetControl.updates) != 2 || targetControl.updates[0].Action != "copied" {
|
||||
t.Fatalf("target updates = %#v", targetControl.updates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package purge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantpurge"
|
||||
)
|
||||
|
||||
type Registry interface {
|
||||
QuiesceForPurge(tenantID string, generation int64) error
|
||||
}
|
||||
|
||||
type ControlPlane interface {
|
||||
AdvancePurge(context.Context, controlplane.PurgeAdvance) error
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DataRoot string
|
||||
BackupRoot string
|
||||
Registry Registry
|
||||
ControlPlane ControlPlane
|
||||
MaxConcurrent int
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
config Config
|
||||
mu sync.Mutex
|
||||
inFlight map[string]struct{}
|
||||
semaphore chan struct{}
|
||||
wait sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(config Config) (*Manager, error) {
|
||||
if config.DataRoot == "" || config.BackupRoot == "" || config.Registry == nil || config.ControlPlane == nil {
|
||||
return nil, errors.New("purge manager requires data, backup, registry, and control-plane ports")
|
||||
}
|
||||
if config.MaxConcurrent <= 0 {
|
||||
config.MaxConcurrent = 1
|
||||
}
|
||||
if config.MaxConcurrent > 4 {
|
||||
return nil, errors.New("purge concurrency is unreasonably high")
|
||||
}
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
return &Manager{
|
||||
config: config, inFlight: make(map[string]struct{}),
|
||||
semaphore: make(chan struct{}, config.MaxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Handle(ctx context.Context, assignments []controlplane.PurgeAssignment) {
|
||||
for _, assignment := range assignments {
|
||||
manager.mu.Lock()
|
||||
if _, exists := manager.inFlight[assignment.PurgeID]; exists {
|
||||
manager.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case manager.semaphore <- struct{}{}:
|
||||
manager.inFlight[assignment.PurgeID] = struct{}{}
|
||||
manager.wait.Add(1)
|
||||
manager.mu.Unlock()
|
||||
go func(assignment controlplane.PurgeAssignment) {
|
||||
defer func() {
|
||||
<-manager.semaphore
|
||||
manager.mu.Lock()
|
||||
delete(manager.inFlight, assignment.PurgeID)
|
||||
manager.mu.Unlock()
|
||||
manager.wait.Done()
|
||||
}()
|
||||
if err := manager.process(ctx, assignment); err != nil && ctx.Err() == nil {
|
||||
manager.config.Logger.Error("Relay tenant purge failed",
|
||||
"purge_id", assignment.PurgeID, "tenant_id", assignment.TenantID, "error", err)
|
||||
}
|
||||
}(assignment)
|
||||
default:
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) process(ctx context.Context, assignment controlplane.PurgeAssignment) error {
|
||||
if assignment.PurgeID == "" || assignment.TenantID == "" || assignment.PlacementGeneration < 1 {
|
||||
return errors.New("purge assignment is incomplete")
|
||||
}
|
||||
if err := manager.config.Registry.QuiesceForPurge(
|
||||
assignment.TenantID, assignment.PlacementGeneration,
|
||||
); err != nil {
|
||||
return manager.fail(ctx, assignment, "relay_purge_quiesce_failed", err)
|
||||
}
|
||||
result, err := tenantpurge.Purge(
|
||||
ctx, manager.config.DataRoot, manager.config.BackupRoot,
|
||||
assignment.TenantID, assignment.PlacementGeneration,
|
||||
)
|
||||
if err != nil {
|
||||
return manager.fail(ctx, assignment, "relay_purge_delete_failed", err)
|
||||
}
|
||||
return manager.config.ControlPlane.AdvancePurge(ctx, controlplane.PurgeAdvance{
|
||||
PurgeID: assignment.PurgeID, Action: "completed", EvidenceSHA256: result.EvidenceSHA256,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) fail(
|
||||
ctx context.Context, assignment controlplane.PurgeAssignment, code string, cause error,
|
||||
) error {
|
||||
if err := manager.config.ControlPlane.AdvancePurge(ctx, controlplane.PurgeAdvance{
|
||||
PurgeID: assignment.PurgeID, Action: "failed", ErrorCode: code,
|
||||
}); err != nil {
|
||||
return errors.Join(cause, err)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (manager *Manager) Wait() { manager.wait.Wait() }
|
||||
@@ -0,0 +1,64 @@
|
||||
package purge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
)
|
||||
|
||||
type fakeRegistry struct{ calls int }
|
||||
|
||||
func (registry *fakeRegistry) QuiesceForPurge(string, int64) error {
|
||||
registry.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeControl struct{ updates []controlplane.PurgeAdvance }
|
||||
|
||||
func (control *fakeControl) AdvancePurge(_ context.Context, update controlplane.PurgeAdvance) error {
|
||||
control.updates = append(control.updates, update)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestManagerQuiescesDeletesAndReportsEvidence(t *testing.T) {
|
||||
const tenantID = "tenant_0123456789abcdef0123456789abcdef"
|
||||
dataRoot := t.TempDir()
|
||||
backupRoot := t.TempDir()
|
||||
paths, err := tenantfs.Resolve(dataRoot, tenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Database, []byte("data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backupTenant := filepath.Join(backupRoot, filepath.Base(paths.Root), "backup")
|
||||
if err := os.MkdirAll(backupTenant, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := &fakeRegistry{}
|
||||
control := &fakeControl{}
|
||||
manager, err := New(Config{
|
||||
DataRoot: dataRoot, BackupRoot: backupRoot, Registry: registry, ControlPlane: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assignment := controlplane.PurgeAssignment{
|
||||
PurgeID: "purge_0123456789abcdef0123456789abcdef",
|
||||
TenantID: tenantID, PlacementGeneration: 7,
|
||||
}
|
||||
if err := manager.process(context.Background(), assignment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registry.calls != 1 || len(control.updates) != 1 || control.updates[0].Action != "completed" ||
|
||||
len(control.updates[0].EvidenceSHA256) != 64 {
|
||||
t.Fatalf("registry=%d updates=%#v", registry.calls, control.updates)
|
||||
}
|
||||
if _, err := os.Lstat(paths.Root); !os.IsNotExist(err) {
|
||||
t.Fatalf("tenant data survived: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package pushsink
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/klarkxy/nekonest/relaycore"
|
||||
corestore "github.com/klarkxy/nekonest/relaycore/store"
|
||||
webpush "github.com/klarkxy/nekonest/relaycore/webpush"
|
||||
)
|
||||
|
||||
type Sink struct{}
|
||||
|
||||
func (Sink) Enabled() bool { return webpush.Enabled() }
|
||||
func (Sink) PublicKey() string { return webpush.PublicKey() }
|
||||
|
||||
func (Sink) Validate(endpoint, p256dh, auth string) error {
|
||||
if err := webpush.ValidateEndpoint(endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
return webpush.ValidateKeys(p256dh, auth)
|
||||
}
|
||||
|
||||
func (Sink) Send(_ context.Context, subscriptions []corestore.PushSubscription, message relaycore.PushMessage, onGone func(string)) bool {
|
||||
converted := make([]webpush.Subscription, 0, len(subscriptions))
|
||||
for _, subscription := range subscriptions {
|
||||
converted = append(converted, webpush.Subscription{
|
||||
Endpoint: subscription.Endpoint,
|
||||
P256DH: subscription.P256DH,
|
||||
Auth: subscription.Auth,
|
||||
})
|
||||
}
|
||||
return webpush.Send(converted, message.Title, message.Body, message.URL, message.DeviceID, message.SessionID, onGone)
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/attachmentstore"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantstore"
|
||||
"github.com/klarkxy/nekonest/relaycore"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
type SnapshotSource interface {
|
||||
AuthorizationSnapshot(context.Context, string, int64) (controlplane.SnapshotResponse, error)
|
||||
AuthorizationDelta(context.Context, string, int64) (controlplane.DeltaResponse, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DataRoot string
|
||||
NodeID string
|
||||
AllowedOrigins []string
|
||||
RouteSecret []byte
|
||||
AppVersion string
|
||||
MaxTenants int
|
||||
Keyring authsnapshot.Keyring
|
||||
ControlPlane SnapshotSource
|
||||
RefreshInterval time.Duration
|
||||
DeltaInterval time.Duration
|
||||
RequestTimeout time.Duration
|
||||
PushSink relaycore.PushSink
|
||||
AuditSink relaycore.AuditSink
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
config Config
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.RWMutex
|
||||
slots map[string]*slot
|
||||
routeHints map[string]*Tenant
|
||||
phoneRoutes map[string]*Tenant
|
||||
deviceOwners map[string]string
|
||||
phoneOwners map[string]string
|
||||
activeTenants int
|
||||
}
|
||||
|
||||
type slot struct {
|
||||
mu sync.Mutex
|
||||
tenant *Tenant
|
||||
reserved bool // guarded by Registry.mu; counts active or currently building Engines
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
mu sync.RWMutex
|
||||
engine *relaycore.Engine
|
||||
store *tenantstore.DB
|
||||
handler http.Handler
|
||||
payload authsnapshot.Payload
|
||||
routeHint string
|
||||
cancel context.CancelFunc
|
||||
closed bool
|
||||
deviceIDs map[string]struct{}
|
||||
phoneIDs map[string]struct{}
|
||||
}
|
||||
|
||||
type attachmentURLBuilder struct{ routeHint string }
|
||||
|
||||
func (builder attachmentURLBuilder) BuildAttachmentURL(id, capabilityKey string) string {
|
||||
query := make(url.Values)
|
||||
query.Set("route", builder.routeHint)
|
||||
query.Set("k", capabilityKey)
|
||||
return "/api/attachments/" + url.PathEscape(id) + "?" + query.Encode()
|
||||
}
|
||||
|
||||
func New(config Config) (*Registry, error) {
|
||||
if config.DataRoot == "" || config.NodeID == "" || len(config.RouteSecret) < 32 {
|
||||
return nil, errors.New("registry requires data root, node id, and a 32-byte route secret")
|
||||
}
|
||||
if config.MaxTenants <= 0 {
|
||||
config.MaxTenants = 256
|
||||
}
|
||||
if config.RefreshInterval <= 0 {
|
||||
config.RefreshInterval = 60 * time.Second
|
||||
}
|
||||
if config.DeltaInterval <= 0 {
|
||||
// Poll more frequently than the public 15-second revocation objective:
|
||||
// one healthy delta request plus one full snapshot refresh must still fit.
|
||||
config.DeltaInterval = 5 * time.Second
|
||||
}
|
||||
if config.RequestTimeout <= 0 {
|
||||
config.RequestTimeout = 3 * time.Second
|
||||
}
|
||||
if config.Now == nil {
|
||||
config.Now = time.Now
|
||||
}
|
||||
if len(config.Keyring) == 0 || config.ControlPlane == nil {
|
||||
return nil, errors.New("registry requires pinned snapshot keys and a control plane")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Registry{
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
slots: make(map[string]*slot),
|
||||
routeHints: make(map[string]*Tenant),
|
||||
phoneRoutes: make(map[string]*Tenant),
|
||||
deviceOwners: make(map[string]string),
|
||||
phoneOwners: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type principalReservation struct {
|
||||
devices []string
|
||||
phones []string
|
||||
}
|
||||
|
||||
func (r *Registry) reservePrincipals(payload authsnapshot.Payload) (principalReservation, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, device := range payload.Devices {
|
||||
if owner := r.deviceOwners[device.DeviceID]; owner != "" && owner != payload.TenantID {
|
||||
return principalReservation{}, errors.New("device identity is already owned by another tenant")
|
||||
}
|
||||
}
|
||||
for _, phone := range payload.Phones {
|
||||
if owner := r.phoneOwners[phone.PhoneID]; owner != "" && owner != payload.TenantID {
|
||||
return principalReservation{}, errors.New("phone identity is already owned by another tenant")
|
||||
}
|
||||
}
|
||||
reservation := principalReservation{}
|
||||
for _, device := range payload.Devices {
|
||||
if r.deviceOwners[device.DeviceID] == "" {
|
||||
r.deviceOwners[device.DeviceID] = payload.TenantID
|
||||
reservation.devices = append(reservation.devices, device.DeviceID)
|
||||
}
|
||||
}
|
||||
for _, phone := range payload.Phones {
|
||||
if r.phoneOwners[phone.PhoneID] == "" {
|
||||
r.phoneOwners[phone.PhoneID] = payload.TenantID
|
||||
reservation.phones = append(reservation.phones, phone.PhoneID)
|
||||
}
|
||||
}
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
func (r *Registry) releaseReservation(tenantID string, reservation principalReservation) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, id := range reservation.devices {
|
||||
if r.deviceOwners[id] == tenantID {
|
||||
delete(r.deviceOwners, id)
|
||||
}
|
||||
}
|
||||
for _, id := range reservation.phones {
|
||||
if r.phoneOwners[id] == tenantID {
|
||||
delete(r.phoneOwners, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) releaseRemovedPrincipals(tenantID string, oldDevices, oldPhones map[string]struct{}, payload authsnapshot.Payload) {
|
||||
newDevices := make(map[string]struct{}, len(payload.Devices))
|
||||
for _, device := range payload.Devices {
|
||||
newDevices[device.DeviceID] = struct{}{}
|
||||
}
|
||||
newPhones := make(map[string]struct{}, len(payload.Phones))
|
||||
for _, phone := range payload.Phones {
|
||||
newPhones[phone.PhoneID] = struct{}{}
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id := range oldDevices {
|
||||
if _, exists := newDevices[id]; !exists && r.deviceOwners[id] == tenantID {
|
||||
delete(r.deviceOwners, id)
|
||||
}
|
||||
}
|
||||
for id := range oldPhones {
|
||||
if _, exists := newPhones[id]; !exists && r.phoneOwners[id] == tenantID {
|
||||
delete(r.phoneOwners, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSet(source map[string]struct{}) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(source))
|
||||
for key := range source {
|
||||
result[key] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Registry) routeHint(tenantID string, generation int64) string {
|
||||
key := sha256.Sum256(append([]byte("nekonest-cloud/engine-route-key/v1\x00"), r.config.RouteSecret...))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return ""
|
||||
}
|
||||
plaintext := make([]byte, len(tenantID)+8)
|
||||
copy(plaintext, tenantID)
|
||||
binary.BigEndian.PutUint64(plaintext[len(tenantID):], uint64(generation))
|
||||
sealed := aead.Seal(nil, nonce, plaintext, []byte("nekonest-cloud/engine-route/v1"))
|
||||
token := append([]byte{1}, nonce...)
|
||||
token = append(token, sealed...)
|
||||
return base64.RawURLEncoding.EncodeToString(token)
|
||||
}
|
||||
|
||||
// DecodeRouteHint authenticates an opaque attachment routing token generated
|
||||
// by any Relay node sharing the deployment route secret. It never accepts a
|
||||
// client-supplied raw tenant id.
|
||||
func (r *Registry) DecodeRouteHint(routeHint string) (string, int64, error) {
|
||||
encoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(routeHint))
|
||||
if err != nil || len(encoded) < 2 || encoded[0] != 1 {
|
||||
return "", 0, errors.New("invalid route hint")
|
||||
}
|
||||
key := sha256.Sum256(append([]byte("nekonest-cloud/engine-route-key/v1\x00"), r.config.RouteSecret...))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if len(encoded) <= 1+aead.NonceSize() {
|
||||
return "", 0, errors.New("invalid route hint")
|
||||
}
|
||||
nonce := encoded[1 : 1+aead.NonceSize()]
|
||||
plaintext, err := aead.Open(nil, nonce, encoded[1+aead.NonceSize():], []byte("nekonest-cloud/engine-route/v1"))
|
||||
if err != nil || len(plaintext) != len("tenant_")+32+8 {
|
||||
return "", 0, errors.New("invalid route hint")
|
||||
}
|
||||
tenantID := string(plaintext[:len(plaintext)-8])
|
||||
generation := int64(binary.BigEndian.Uint64(plaintext[len(plaintext)-8:]))
|
||||
if !strings.HasPrefix(tenantID, "tenant_") || generation < 1 {
|
||||
return "", 0, errors.New("invalid route hint")
|
||||
}
|
||||
for _, char := range strings.TrimPrefix(tenantID, "tenant_") {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return "", 0, errors.New("invalid route hint")
|
||||
}
|
||||
}
|
||||
return tenantID, generation, nil
|
||||
}
|
||||
|
||||
func (r *Registry) engineAdminSecret(tenantID string, generation int64) string {
|
||||
mac := hmac.New(sha256.New, r.config.RouteSecret)
|
||||
_, _ = fmt.Fprintf(mac, "nekonest-cloud/engine-admin/v1\x00%s\x00%d", tenantID, generation)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (r *Registry) verifyResponse(response controlplane.SnapshotResponse) (authsnapshot.Payload, error) {
|
||||
key, ok := r.config.Keyring[response.Snapshot.KID]
|
||||
if !ok {
|
||||
return authsnapshot.Payload{}, errors.New("snapshot key id is not pinned")
|
||||
}
|
||||
responseKey, responseKID, err := authsnapshot.PublicKeyFromJWK(response.PublicKeyJWK)
|
||||
if err != nil {
|
||||
return authsnapshot.Payload{}, err
|
||||
}
|
||||
if responseKID != "" && responseKID != response.Snapshot.KID {
|
||||
return authsnapshot.Payload{}, errors.New("snapshot JWK kid mismatch")
|
||||
}
|
||||
if len(responseKey) != len(key.PublicKey) || subtle.ConstantTimeCompare(responseKey, key.PublicKey) != 1 {
|
||||
return authsnapshot.Payload{}, errors.New("control-plane key does not match pinned keyring")
|
||||
}
|
||||
payload, err := authsnapshot.Verify(response.Snapshot, r.config.Keyring, r.config.Now())
|
||||
if err != nil {
|
||||
return authsnapshot.Payload{}, err
|
||||
}
|
||||
if payload.RelayNodeID != r.config.NodeID {
|
||||
return authsnapshot.Payload{}, errors.New("snapshot belongs to another relay node")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (r *Registry) getSlot(tenantID string) (*slot, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if existing := r.slots[tenantID]; existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
created := &slot{}
|
||||
r.slots[tenantID] = created
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (r *Registry) reserveSlot(slot *slot) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if slot.reserved {
|
||||
return nil
|
||||
}
|
||||
if r.activeTenants >= r.config.MaxTenants {
|
||||
return errors.New("relay tenant capacity reached")
|
||||
}
|
||||
slot.reserved = true
|
||||
r.activeTenants++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) releaseSlot(slot *slot) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !slot.reserved {
|
||||
return
|
||||
}
|
||||
slot.reserved = false
|
||||
if r.activeTenants > 0 {
|
||||
r.activeTenants--
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Accept(ctx context.Context, response controlplane.SnapshotResponse) (*Tenant, error) {
|
||||
payload, err := r.verifyResponse(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slot, err := r.getSlot(payload.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
|
||||
if current := slot.tenant; current != nil {
|
||||
current.mu.RLock()
|
||||
generation := current.payload.PlacementGeneration
|
||||
authorizationRevision := current.payload.AuthorizationRevision
|
||||
closed := current.closed
|
||||
oldDevices := cloneSet(current.deviceIDs)
|
||||
oldPhones := cloneSet(current.phoneIDs)
|
||||
current.mu.RUnlock()
|
||||
if !closed && generation == payload.PlacementGeneration {
|
||||
// Snapshot refreshes can complete out of order. Never let an older,
|
||||
// still-valid signed snapshot re-authorize a principal removed by a
|
||||
// newer revision.
|
||||
if payload.AuthorizationRevision < authorizationRevision {
|
||||
return nil, errors.New("stale authorization revision")
|
||||
}
|
||||
reservation, reserveErr := r.reservePrincipals(payload)
|
||||
if reserveErr != nil {
|
||||
return nil, reserveErr
|
||||
}
|
||||
if err := current.applyPayload(payload); err != nil {
|
||||
r.releaseReservation(payload.TenantID, reservation)
|
||||
r.expireLocked(slot, current)
|
||||
r.releaseSlot(slot)
|
||||
return nil, err
|
||||
}
|
||||
r.releaseRemovedPrincipals(payload.TenantID, oldDevices, oldPhones, payload)
|
||||
return current, nil
|
||||
}
|
||||
if payload.PlacementGeneration < generation {
|
||||
return nil, errors.New("stale placement generation")
|
||||
}
|
||||
r.expireLocked(slot, current)
|
||||
}
|
||||
if err := r.reserveSlot(slot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reservation, err := r.reservePrincipals(payload)
|
||||
if err != nil {
|
||||
r.releaseSlot(slot)
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := r.buildTenant(payload)
|
||||
if err != nil {
|
||||
r.releaseReservation(payload.TenantID, reservation)
|
||||
r.releaseSlot(slot)
|
||||
return nil, err
|
||||
}
|
||||
slot.tenant = tenant
|
||||
r.mu.Lock()
|
||||
r.routeHints[tenant.routeHint] = tenant
|
||||
r.mu.Unlock()
|
||||
watchContext, cancel := context.WithCancel(r.ctx)
|
||||
tenant.cancel = cancel
|
||||
go r.watch(watchContext, slot, tenant)
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (r *Registry) buildTenant(payload authsnapshot.Payload) (*Tenant, error) {
|
||||
paths, err := tenantfs.Resolve(r.config.DataRoot, payload.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := tenantstore.NewWithTransportMode(paths.Database, string(protocol.TransportSealed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments, err := attachmentstore.New(paths.Attachments)
|
||||
if err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
routeHint := r.routeHint(payload.TenantID, payload.PlacementGeneration)
|
||||
if routeHint == "" {
|
||||
_ = store.Close()
|
||||
return nil, errors.New("generate tenant route hint")
|
||||
}
|
||||
engine, err := relaycore.NewEngine(relaycore.Config{
|
||||
Store: store,
|
||||
Ports: relaycore.Ports{
|
||||
PrincipalSynchronizer: store,
|
||||
AttachmentStore: attachments,
|
||||
AttachmentURLBuilder: attachmentURLBuilder{routeHint: routeHint},
|
||||
PushSink: r.config.PushSink,
|
||||
AuditSink: r.config.AuditSink,
|
||||
},
|
||||
PhoneSecret: r.engineAdminSecret(payload.TenantID, payload.PlacementGeneration),
|
||||
AppVersion: r.config.AppVersion,
|
||||
TransportMode: protocol.TransportSealed,
|
||||
DuplicateDaemon: relaycore.RejectNew,
|
||||
AllowedOrigins: r.config.AllowedOrigins,
|
||||
})
|
||||
if err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
engine.RegisterDataPlaneRoutes(mux)
|
||||
tenant := &Tenant{
|
||||
engine: engine,
|
||||
store: store,
|
||||
handler: mux,
|
||||
payload: payload,
|
||||
routeHint: routeHint,
|
||||
deviceIDs: make(map[string]struct{}),
|
||||
phoneIDs: make(map[string]struct{}),
|
||||
}
|
||||
if err := tenant.applyPayload(payload); err != nil {
|
||||
_ = engine.Close()
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (tenant *Tenant) applyPayload(payload authsnapshot.Payload) error {
|
||||
tenant.mu.Lock()
|
||||
defer tenant.mu.Unlock()
|
||||
if tenant.closed {
|
||||
return errors.New("tenant engine is closed")
|
||||
}
|
||||
next := make(map[string]struct{}, len(payload.Devices))
|
||||
for _, device := range payload.Devices {
|
||||
if err := tenant.engine.SyncApprovedDevice(relaycore.ApprovedDevice{
|
||||
ID: device.DeviceID,
|
||||
Name: device.Name,
|
||||
OS: device.OS,
|
||||
Credential: relaycore.Credential{Kind: relaycore.CredentialSHA256, Value: device.CredentialHash},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("sync device %s: %w", device.DeviceID, err)
|
||||
}
|
||||
if err := tenant.store.SetDevicePublicKeys(
|
||||
device.DeviceID,
|
||||
device.Ed25519Public,
|
||||
device.X25519Public,
|
||||
device.IdentityFingerprint,
|
||||
); err != nil {
|
||||
return fmt.Errorf("sync device identity %s: %w", device.DeviceID, err)
|
||||
}
|
||||
next[device.DeviceID] = struct{}{}
|
||||
}
|
||||
for deviceID := range tenant.deviceIDs {
|
||||
if _, exists := next[deviceID]; !exists {
|
||||
if err := tenant.engine.RevokeApprovedDevice(deviceID); err != nil {
|
||||
return fmt.Errorf("revoke device %s: %w", deviceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
nextPhones := make(map[string]struct{}, len(payload.Phones))
|
||||
for _, phone := range payload.Phones {
|
||||
if err := tenant.engine.SyncApprovedPhone(relaycore.ApprovedPhone{
|
||||
ID: phone.PhoneID,
|
||||
Name: phone.Name,
|
||||
Credential: relaycore.Credential{Kind: relaycore.CredentialSHA256, Value: phone.CredentialHash},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("sync phone %s: %w", phone.PhoneID, err)
|
||||
}
|
||||
if err := tenant.store.SetPhonePublicKeys(phone.PhoneID, phone.Ed25519Public, phone.X25519Public); err != nil {
|
||||
return fmt.Errorf("sync phone identity %s: %w", phone.PhoneID, err)
|
||||
}
|
||||
nextPhones[phone.PhoneID] = struct{}{}
|
||||
}
|
||||
for phoneID := range tenant.phoneIDs {
|
||||
if _, exists := nextPhones[phoneID]; !exists {
|
||||
if err := tenant.engine.RevokeApprovedPhone(phoneID); err != nil {
|
||||
return fmt.Errorf("revoke phone %s: %w", phoneID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
tenant.deviceIDs = next
|
||||
tenant.phoneIDs = nextPhones
|
||||
tenant.payload = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) watch(ctx context.Context, slot *slot, tenant *Tenant) {
|
||||
refresh := time.NewTicker(r.config.RefreshInterval)
|
||||
delta := time.NewTicker(r.config.DeltaInterval)
|
||||
defer refresh.Stop()
|
||||
defer delta.Stop()
|
||||
for {
|
||||
tenant.mu.RLock()
|
||||
payload := tenant.payload
|
||||
closed := tenant.closed
|
||||
tenant.mu.RUnlock()
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, payload.ExpiresAt)
|
||||
if err != nil {
|
||||
r.expire(slot, tenant)
|
||||
return
|
||||
}
|
||||
untilExpiry := expiresAt.Sub(r.config.Now())
|
||||
if untilExpiry <= 0 {
|
||||
r.expire(slot, tenant)
|
||||
return
|
||||
}
|
||||
expiry := time.NewTimer(untilExpiry)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
expiry.Stop()
|
||||
return
|
||||
case <-expiry.C:
|
||||
r.expire(slot, tenant)
|
||||
return
|
||||
case <-refresh.C:
|
||||
expiry.Stop()
|
||||
r.refresh(ctx, tenant, payload)
|
||||
case <-delta.C:
|
||||
expiry.Stop()
|
||||
requestContext, cancel := context.WithTimeout(ctx, r.config.RequestTimeout)
|
||||
change, err := r.config.ControlPlane.AuthorizationDelta(
|
||||
requestContext, payload.TenantID, payload.AuthorizationRevision,
|
||||
)
|
||||
cancel()
|
||||
if err == nil && change.TenantStatus != "active" {
|
||||
r.expire(slot, tenant)
|
||||
return
|
||||
}
|
||||
if err == nil && change.Changed {
|
||||
r.refresh(ctx, tenant, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) refresh(ctx context.Context, tenant *Tenant, payload authsnapshot.Payload) {
|
||||
requestContext, cancel := context.WithTimeout(ctx, r.config.RequestTimeout)
|
||||
response, err := r.config.ControlPlane.AuthorizationSnapshot(
|
||||
requestContext, payload.TenantID, payload.PlacementGeneration,
|
||||
)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return // existing associations continue only until the current snapshot expires
|
||||
}
|
||||
_, _ = r.Accept(ctx, response)
|
||||
}
|
||||
|
||||
func (r *Registry) expire(slot *slot, tenant *Tenant) {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.tenant == tenant {
|
||||
r.expireLocked(slot, tenant)
|
||||
r.releaseSlot(slot)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) expireLocked(slot *slot, tenant *Tenant) {
|
||||
tenant.mu.Lock()
|
||||
if tenant.closed {
|
||||
tenant.mu.Unlock()
|
||||
if slot.tenant == tenant {
|
||||
slot.tenant = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
tenant.closed = true
|
||||
cancel := tenant.cancel
|
||||
tenantID := tenant.payload.TenantID
|
||||
deviceIDs := cloneSet(tenant.deviceIDs)
|
||||
phoneIDs := cloneSet(tenant.phoneIDs)
|
||||
tenant.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
_ = tenant.engine.Close()
|
||||
_ = tenant.store.Close()
|
||||
r.mu.Lock()
|
||||
for id := range deviceIDs {
|
||||
if r.deviceOwners[id] == tenantID {
|
||||
delete(r.deviceOwners, id)
|
||||
}
|
||||
}
|
||||
for id := range phoneIDs {
|
||||
if r.phoneOwners[id] == tenantID {
|
||||
delete(r.phoneOwners, id)
|
||||
}
|
||||
}
|
||||
if r.routeHints[tenant.routeHint] == tenant {
|
||||
delete(r.routeHints, tenant.routeHint)
|
||||
}
|
||||
for digest, routed := range r.phoneRoutes {
|
||||
if routed == tenant {
|
||||
delete(r.phoneRoutes, digest)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if slot.tenant == tenant {
|
||||
slot.tenant = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) ByRouteHint(routeHint string) (*Tenant, bool) {
|
||||
r.mu.RLock()
|
||||
tenant := r.routeHints[routeHint]
|
||||
r.mu.RUnlock()
|
||||
if tenant == nil {
|
||||
return nil, false
|
||||
}
|
||||
tenant.mu.RLock()
|
||||
defer tenant.mu.RUnlock()
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, tenant.payload.ExpiresAt)
|
||||
return tenant, !tenant.closed && err == nil && r.config.Now().Before(expiresAt)
|
||||
}
|
||||
|
||||
func phoneRouteDigest(routeHandle string) string {
|
||||
digest := sha256.Sum256([]byte(routeHandle))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// BindPhoneRoute caches only a one-way digest of the opaque handle. The handle
|
||||
// is a routing hint and never authorizes a request without the phone token.
|
||||
func (r *Registry) BindPhoneRoute(routeHandle string, tenant *Tenant) error {
|
||||
if len(routeHandle) != 64 || tenant == nil {
|
||||
return errors.New("invalid phone route handle")
|
||||
}
|
||||
for _, char := range routeHandle {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return errors.New("invalid phone route handle")
|
||||
}
|
||||
}
|
||||
if !tenant.Current(r.config.Now()) {
|
||||
return errors.New("tenant authorization snapshot expired")
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.phoneRoutes[phoneRouteDigest(routeHandle)] = tenant
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) ByPhoneRoute(routeHandle string) (*Tenant, bool) {
|
||||
r.mu.RLock()
|
||||
tenant := r.phoneRoutes[phoneRouteDigest(routeHandle)]
|
||||
r.mu.RUnlock()
|
||||
return tenant, tenant != nil && tenant.Current(r.config.Now())
|
||||
}
|
||||
|
||||
func (r *Registry) ByDeviceID(deviceID string) (*Tenant, bool) {
|
||||
r.mu.RLock()
|
||||
slots := make([]*slot, 0, len(r.slots))
|
||||
for _, item := range r.slots {
|
||||
slots = append(slots, item)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
for _, item := range slots {
|
||||
item.mu.Lock()
|
||||
tenant := item.tenant
|
||||
item.mu.Unlock()
|
||||
if tenant == nil || !tenant.Current(r.config.Now()) {
|
||||
continue
|
||||
}
|
||||
tenant.mu.RLock()
|
||||
_, exists := tenant.deviceIDs[deviceID]
|
||||
tenant.mu.RUnlock()
|
||||
if exists {
|
||||
return tenant, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (tenant *Tenant) Handler() http.Handler { return tenant.handler }
|
||||
func (tenant *Tenant) Engine() *relaycore.Engine { return tenant.engine }
|
||||
func (tenant *Tenant) RouteHint() string { return tenant.routeHint }
|
||||
|
||||
func (tenant *Tenant) Current(now time.Time) bool {
|
||||
tenant.mu.RLock()
|
||||
defer tenant.mu.RUnlock()
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, tenant.payload.ExpiresAt)
|
||||
return !tenant.closed && err == nil && now.Before(expiresAt)
|
||||
}
|
||||
|
||||
func (tenant *Tenant) Payload() authsnapshot.Payload {
|
||||
tenant.mu.RLock()
|
||||
defer tenant.mu.RUnlock()
|
||||
return tenant.payload
|
||||
}
|
||||
|
||||
func (tenant *Tenant) SyncPhone(phoneID, name, tokenHash, ed25519Public, x25519Public string) error {
|
||||
if err := tenant.engine.SyncApprovedPhone(relaycore.ApprovedPhone{
|
||||
ID: phoneID,
|
||||
Name: name,
|
||||
Credential: relaycore.Credential{Kind: relaycore.CredentialSHA256, Value: tokenHash},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tenant.store.SetPhonePublicKeys(phoneID, ed25519Public, x25519Public)
|
||||
}
|
||||
|
||||
func (tenant *Tenant) RevokePhone(phoneID string) error {
|
||||
return tenant.engine.RevokeApprovedPhone(phoneID)
|
||||
}
|
||||
|
||||
// Quiesce closes the current generation and releases its in-process capacity
|
||||
// before a backup is taken. The control plane must already have fenced the
|
||||
// placement out of active state, so a new signed snapshot cannot reopen it.
|
||||
func (r *Registry) Quiesce(tenantID string, generation int64) (tenantfs.Paths, error) {
|
||||
paths, err := tenantfs.Derive(r.config.DataRoot, tenantID)
|
||||
if err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
r.mu.RLock()
|
||||
slot := r.slots[tenantID]
|
||||
r.mu.RUnlock()
|
||||
if slot == nil {
|
||||
info, statErr := os.Lstat(paths.Database)
|
||||
if statErr == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular() {
|
||||
return paths, nil // idempotent retry after the Engine was already closed
|
||||
}
|
||||
return tenantfs.Paths{}, errors.New("tenant engine is not active on this node")
|
||||
}
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
tenant := slot.tenant
|
||||
if tenant == nil {
|
||||
info, statErr := os.Lstat(paths.Database)
|
||||
if statErr == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular() {
|
||||
return paths, nil
|
||||
}
|
||||
return tenantfs.Paths{}, errors.New("tenant engine is not active on this node")
|
||||
}
|
||||
tenant.mu.RLock()
|
||||
currentGeneration := tenant.payload.PlacementGeneration
|
||||
tenant.mu.RUnlock()
|
||||
if generation < 1 || currentGeneration != generation {
|
||||
return tenantfs.Paths{}, errors.New("tenant placement generation mismatch")
|
||||
}
|
||||
r.expireLocked(slot, tenant)
|
||||
r.releaseSlot(slot)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// QuiesceForPurge fences and closes an active generation, but unlike migration
|
||||
// quiescing it also succeeds when a retry finds that the tenant data was
|
||||
// already deleted. The exact generation is still enforced for a live Engine.
|
||||
func (r *Registry) QuiesceForPurge(tenantID string, generation int64) error {
|
||||
paths, err := tenantfs.Derive(r.config.DataRoot, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generation < 1 {
|
||||
return errors.New("invalid tenant placement generation")
|
||||
}
|
||||
r.mu.RLock()
|
||||
slot := r.slots[tenantID]
|
||||
r.mu.RUnlock()
|
||||
if slot == nil {
|
||||
return validPurgeRoot(paths.Root)
|
||||
}
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
tenant := slot.tenant
|
||||
if tenant == nil {
|
||||
return validPurgeRoot(paths.Root)
|
||||
}
|
||||
tenant.mu.RLock()
|
||||
currentGeneration := tenant.payload.PlacementGeneration
|
||||
tenant.mu.RUnlock()
|
||||
if currentGeneration != generation {
|
||||
return errors.New("tenant placement generation mismatch")
|
||||
}
|
||||
r.expireLocked(slot, tenant)
|
||||
r.releaseSlot(slot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPurgeRoot(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("tenant purge root is not a real directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Close() error {
|
||||
r.cancel()
|
||||
r.mu.RLock()
|
||||
slots := make([]*slot, 0, len(r.slots))
|
||||
for _, slot := range r.slots {
|
||||
slots = append(slots, slot)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
for _, slot := range slots {
|
||||
slot.mu.Lock()
|
||||
if slot.tenant != nil {
|
||||
r.expireLocked(slot, slot.tenant)
|
||||
r.releaseSlot(slot)
|
||||
}
|
||||
slot.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
)
|
||||
|
||||
type snapshotSource struct{}
|
||||
|
||||
func (snapshotSource) AuthorizationSnapshot(context.Context, string, int64) (controlplane.SnapshotResponse, error) {
|
||||
return controlplane.SnapshotResponse{}, context.Canceled
|
||||
}
|
||||
func (snapshotSource) AuthorizationDelta(context.Context, string, int64) (controlplane.DeltaResponse, error) {
|
||||
return controlplane.DeltaResponse{}, context.Canceled
|
||||
}
|
||||
|
||||
type signer struct {
|
||||
public ed25519.PublicKey
|
||||
private ed25519.PrivateKey
|
||||
keyring authsnapshot.Keyring
|
||||
}
|
||||
|
||||
func newSigner(t *testing.T, now time.Time) signer {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signer{
|
||||
public: public,
|
||||
private: private,
|
||||
keyring: authsnapshot.Keyring{"key": {
|
||||
PublicKey: public, NotBefore: now.Add(-time.Hour), RetainUntil: now.Add(time.Hour),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func (signer signer) response(t *testing.T, now time.Time, tenantID, deviceID string) controlplane.SnapshotResponse {
|
||||
t.Helper()
|
||||
key := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
payload := authsnapshot.Payload{
|
||||
SnapshotVersion: 1,
|
||||
TenantID: tenantID,
|
||||
TenantStatus: "active",
|
||||
HomeRegion: "test",
|
||||
RelayNodeID: "node_test",
|
||||
PlacementGeneration: 1,
|
||||
AuthorizationRevision: 1,
|
||||
Devices: []authsnapshot.Device{{
|
||||
DeviceID: deviceID,
|
||||
CredentialHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
IdentityFingerprint: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
Ed25519Public: key,
|
||||
X25519Public: key,
|
||||
Name: "Host",
|
||||
OS: "linux",
|
||||
}},
|
||||
IssuedAt: now.Add(-time.Second).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(4 * time.Minute).Format(time.RFC3339Nano),
|
||||
}
|
||||
bytes, err := authsnapshot.SigningBytes(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, _ := json.Marshal(map[string]string{
|
||||
"kty": "OKP", "crv": "Ed25519", "kid": "key",
|
||||
"x": base64.RawURLEncoding.EncodeToString(signer.public),
|
||||
})
|
||||
return controlplane.SnapshotResponse{
|
||||
Snapshot: authsnapshot.Signed{
|
||||
Algorithm: "Ed25519", KID: "key", Payload: payload,
|
||||
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer.private, bytes)),
|
||||
},
|
||||
PublicKeyJWK: jwk,
|
||||
}
|
||||
}
|
||||
|
||||
func (signer signer) resign(t *testing.T, response controlplane.SnapshotResponse) controlplane.SnapshotResponse {
|
||||
t.Helper()
|
||||
bytes, err := authsnapshot.SigningBytes(response.Snapshot.Payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Snapshot.Signature = base64.RawURLEncoding.EncodeToString(ed25519.Sign(signer.private, bytes))
|
||||
return response
|
||||
}
|
||||
|
||||
func TestDefaultAuthorizationPollingFitsRevocationObjective(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
if registryValue.config.DeltaInterval != 5*time.Second || registryValue.config.RequestTimeout != 3*time.Second {
|
||||
t.Fatalf("delta=%s timeout=%s", registryValue.config.DeltaInterval, registryValue.config.RequestTimeout)
|
||||
}
|
||||
if worstHealthy := registryValue.config.DeltaInterval + 2*registryValue.config.RequestTimeout; worstHealthy >= 15*time.Second {
|
||||
t.Fatalf("healthy revocation path exceeds objective: %s", worstHealthy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAcceptReturnsSingleTenantEngine(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
RefreshInterval: time.Hour, DeltaInterval: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
response := signer.response(t, now,
|
||||
"tenant_0123456789abcdef0123456789abcdef",
|
||||
"host_0123456789abcdef0123456789abcdef",
|
||||
)
|
||||
results := make([]*Tenant, 8)
|
||||
errorsFound := make([]error, 8)
|
||||
var wait sync.WaitGroup
|
||||
for index := range results {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
results[index], errorsFound[index] = registryValue.Accept(context.Background(), response)
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
for index := range results {
|
||||
if errorsFound[index] != nil || results[index] != results[0] {
|
||||
t.Fatalf("result[%d]=%p err=%v; first=%p", index, results[index], errorsFound[index], results[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityCannotBeOwnedByTwoTenants(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
RefreshInterval: time.Hour, DeltaInterval: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
deviceID := "host_0123456789abcdef0123456789abcdef"
|
||||
first := signer.response(t, now, "tenant_0123456789abcdef0123456789abcdef", deviceID)
|
||||
second := signer.response(t, now, "tenant_fedcba9876543210fedcba9876543210", deviceID)
|
||||
if _, err := registryValue.Accept(context.Background(), first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registryValue.Accept(context.Background(), second); err == nil {
|
||||
t.Fatal("cross-tenant device identity was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlderAuthorizationRevisionCannotRestoreRevokedDevice(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
RefreshInterval: time.Hour, DeltaInterval: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
tenantID := "tenant_0123456789abcdef0123456789abcdef"
|
||||
deviceID := "host_0123456789abcdef0123456789abcdef"
|
||||
revisionOne := signer.response(t, now, tenantID, deviceID)
|
||||
if _, err := registryValue.Accept(context.Background(), revisionOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revisionTwo := revisionOne
|
||||
revisionTwo.Snapshot.Payload.AuthorizationRevision = 2
|
||||
revisionTwo.Snapshot.Payload.Devices = nil
|
||||
revisionTwo = signer.resign(t, revisionTwo)
|
||||
if _, err := registryValue.Accept(context.Background(), revisionTwo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := registryValue.ByDeviceID(deviceID); ok {
|
||||
t.Fatal("revoked device remained routable after newer revision")
|
||||
}
|
||||
if _, err := registryValue.Accept(context.Background(), revisionOne); err == nil {
|
||||
t.Fatal("older signed authorization revision restored a revoked device")
|
||||
}
|
||||
if _, ok := registryValue.ByDeviceID(deviceID); ok {
|
||||
t.Fatal("stale revision restored revoked device routing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredTenantReleasesActiveCapacity(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
RefreshInterval: time.Hour, DeltaInterval: time.Hour, MaxTenants: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
firstTenantID := "tenant_0123456789abcdef0123456789abcdef"
|
||||
first, err := registryValue.Accept(context.Background(), signer.response(
|
||||
t, now, firstTenantID, "host_0123456789abcdef0123456789abcdef",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryValue.mu.RLock()
|
||||
firstSlot := registryValue.slots[firstTenantID]
|
||||
registryValue.mu.RUnlock()
|
||||
registryValue.expire(firstSlot, first)
|
||||
if _, err := registryValue.Accept(context.Background(), signer.response(
|
||||
t, now, "tenant_fedcba9876543210fedcba9876543210", "host_fedcba9876543210fedcba9876543210",
|
||||
)); err != nil {
|
||||
t.Fatalf("capacity was not released after expiry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuiesceFencesExactGeneration(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
RefreshInterval: time.Hour, DeltaInterval: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
tenantID := "tenant_0123456789abcdef0123456789abcdef"
|
||||
if _, err := registryValue.Accept(context.Background(), signer.response(
|
||||
t, now, tenantID, "host_0123456789abcdef0123456789abcdef",
|
||||
)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registryValue.Quiesce(tenantID, 2); err == nil {
|
||||
t.Fatal("wrong generation was quiesced")
|
||||
}
|
||||
paths, err := registryValue.Quiesce(tenantID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if paths.Database == "" {
|
||||
t.Fatal("quiesce did not return the fenced tenant paths")
|
||||
}
|
||||
if _, ok := registryValue.ByDeviceID("host_0123456789abcdef0123456789abcdef"); ok {
|
||||
t.Fatal("quiesced tenant remained routable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteHintIsOpaqueAuthenticatedAndCrossNodeDecodable(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
signer := newSigner(t, now)
|
||||
secret := []byte("0123456789abcdef0123456789abcdef")
|
||||
registryValue, err := New(Config{
|
||||
DataRoot: t.TempDir(), NodeID: "node_test",
|
||||
AllowedOrigins: []string{"https://pwa.example"}, RouteSecret: secret,
|
||||
Keyring: signer.keyring, ControlPlane: snapshotSource{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registryValue.Close()
|
||||
tenantID := "tenant_0123456789abcdef0123456789abcdef"
|
||||
hint := registryValue.routeHint(tenantID, 19)
|
||||
if hint == "" || strings.Contains(hint, tenantID) {
|
||||
t.Fatalf("route hint leaked tenant identity: %q", hint)
|
||||
}
|
||||
decodedTenant, generation, err := registryValue.DecodeRouteHint(hint)
|
||||
if err != nil || decodedTenant != tenantID || generation != 19 {
|
||||
t.Fatalf("decoded tenant=%q generation=%d err=%v", decodedTenant, generation, err)
|
||||
}
|
||||
tamperedBytes, err := base64.RawURLEncoding.DecodeString(hint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tamperedBytes[len(tamperedBytes)/2] ^= 0x01
|
||||
tampered := base64.RawURLEncoding.EncodeToString(tamperedBytes)
|
||||
if _, _, err := registryValue.DecodeRouteHint(tampered); err == nil {
|
||||
t.Fatal("tampered route hint was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/forwarder"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/registry"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRegistrationBody = 64 << 10
|
||||
maxHandoffBody = 64 << 10
|
||||
maxRoutingBody = 1 << 20
|
||||
maxFirstFrame = 128 << 10
|
||||
)
|
||||
|
||||
type ControlPlane interface {
|
||||
ResolveDeviceRoute(context.Context, string, string) (controlplane.RouteResolution, error)
|
||||
ResolvePhoneRoute(context.Context, string, string) (controlplane.RouteResolution, error)
|
||||
ResolveTenantRoute(context.Context, string, int64) (controlplane.RouteResolution, error)
|
||||
ResolveHandoffRoute(context.Context, string, string) (controlplane.RouteResolution, error)
|
||||
AuthorizeDevice(context.Context, string, string) (controlplane.SnapshotResponse, error)
|
||||
AuthorizationSnapshot(context.Context, string, int64) (controlplane.SnapshotResponse, error)
|
||||
AuthorizePhone(context.Context, string, string) (controlplane.PhoneAuthorization, error)
|
||||
ConsumePhoneHandoff(context.Context, any) (controlplane.ConsumedHandoff, error)
|
||||
CompletePhoneHandoff(context.Context, string, string, string, string) error
|
||||
RevokePhone(context.Context, string, string, string) error
|
||||
RegisterDevice(context.Context, string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ControlPlane ControlPlane
|
||||
Registry *registry.Registry
|
||||
AllowedPWAOrigins []string
|
||||
SourceHashSecret []byte
|
||||
TrustedProxyRanges []netip.Prefix
|
||||
AppVersion string
|
||||
NodeID string
|
||||
ForwardSecret []byte
|
||||
HandoffSecret []byte
|
||||
Forwarder *forwarder.Forwarder
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
controlPlane ControlPlane
|
||||
registry *registry.Registry
|
||||
origins map[string]struct{}
|
||||
sourceSecret []byte
|
||||
proxies []netip.Prefix
|
||||
appVersion string
|
||||
nodeID string
|
||||
forwardSecret []byte
|
||||
handoffSecret []byte
|
||||
forwarder *forwarder.Forwarder
|
||||
now func() time.Time
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func New(config Config) (*Service, error) {
|
||||
if config.ControlPlane == nil || config.Registry == nil || len(config.SourceHashSecret) < 32 ||
|
||||
strings.TrimSpace(config.NodeID) == "" || len(config.ForwardSecret) < 32 || len(config.HandoffSecret) < 32 {
|
||||
return nil, errors.New("service requires control plane, registry, and independent 32-byte secrets")
|
||||
}
|
||||
origins := make(map[string]struct{}, len(config.AllowedPWAOrigins))
|
||||
for _, origin := range config.AllowedPWAOrigins {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("service PWA origins must be exact origins")
|
||||
}
|
||||
origins[origin] = struct{}{}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return nil, errors.New("service requires an exact PWA origin allowlist")
|
||||
}
|
||||
if config.Now == nil {
|
||||
config.Now = time.Now
|
||||
}
|
||||
service := &Service{
|
||||
controlPlane: config.ControlPlane,
|
||||
registry: config.Registry,
|
||||
origins: origins,
|
||||
sourceSecret: append([]byte(nil), config.SourceHashSecret...),
|
||||
proxies: append([]netip.Prefix(nil), config.TrustedProxyRanges...),
|
||||
appVersion: strings.TrimSpace(config.AppVersion),
|
||||
nodeID: strings.TrimSpace(config.NodeID),
|
||||
forwardSecret: append([]byte(nil), config.ForwardSecret...),
|
||||
handoffSecret: append([]byte(nil), config.HandoffSecret...),
|
||||
forwarder: config.Forwarder,
|
||||
now: config.Now,
|
||||
}
|
||||
service.upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
EnableCompression: false,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
_, allowed := service.origins[origin]
|
||||
return allowed
|
||||
},
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) Handler() http.Handler { return s.cors(http.HandlerFunc(s.serveHTTP)) }
|
||||
|
||||
func (s *Service) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := forwarder.VerifyIncoming(r, s.nodeID, s.forwardSecret, s.now()); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay forwarding authentication failed", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
s.handleHealth(w, r)
|
||||
case "/ws/daemon":
|
||||
s.handleDaemonWebSocket(w, r)
|
||||
case "/ws/phone":
|
||||
s.handlePhoneWebSocket(w, r)
|
||||
case "/api/devices/register":
|
||||
s.handleDeviceRegistration(w, r)
|
||||
case "/api/pwa/handoff/exchange":
|
||||
s.handlePhoneHandoff(w, r)
|
||||
default:
|
||||
s.handleDataPlane(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin != "" {
|
||||
if _, allowed := s.origins[origin]; !allowed {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "origin is not allowed", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Neko-Phone-Token, X-Neko-Route-Handle")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
if origin == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "origin is required", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "nyan~",
|
||||
"service": "nekonest-cloud-relay",
|
||||
"server_version": s.appVersion,
|
||||
"protocol_version": protocol.CurrentProtocolVersion,
|
||||
"transport_mode": protocol.TransportSealed,
|
||||
})
|
||||
}
|
||||
|
||||
func readBounded(body io.ReadCloser, limit int64) ([]byte, error) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, errors.New("request body exceeds limit")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleDeviceRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := readBounded(r.Body, maxRegistrationBody)
|
||||
if err != nil || !json.Valid(body) {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRegistrationDisabled, "invalid registration request", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
bootstrap := strings.TrimSpace(r.Header.Get("X-Neko-Bootstrap"))
|
||||
if bootstrap == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "bootstrap credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
result, err := s.controlPlane.RegisterDevice(r.Context(), bootstrap, s.sourceHash(r), json.RawMessage(body))
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "registration is temporarily unavailable"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (s *Service) handlePhoneHandoff(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if _, allowed := s.origins[origin]; !allowed {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "PWA origin is not allowed", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
body, err := readBounded(r.Body, maxHandoffBody)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "invalid phone handoff", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Ticket string `json:"ticket"`
|
||||
PWAOrigin string `json:"pwa_origin"`
|
||||
Name string `json:"name"`
|
||||
PhoneEd25519Public string `json:"phone_ed25519_public"`
|
||||
PhoneX25519Public string `json:"phone_x25519_public"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || decoder.Decode(&struct{}{}) != io.EOF || request.PWAOrigin != origin {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "invalid phone handoff", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolveHandoffRoute(r.Context(), request.Ticket, request.PWAOrigin)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone handoff route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
consumed, err := s.controlPlane.ConsumePhoneHandoff(r.Context(), request)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorPhoneCredentialInvalid, "phone handoff was rejected"))
|
||||
return
|
||||
}
|
||||
snapshot, err := s.controlPlane.AuthorizationSnapshot(r.Context(), consumed.TenantID, consumed.PlacementGeneration)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "tenant route is unavailable"))
|
||||
return
|
||||
}
|
||||
tenant, err := s.registry.Accept(r.Context(), snapshot)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "tenant authorization could not be verified", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
phoneID, phoneToken, routeHandle := deriveHandoffCredentials(
|
||||
s.handoffSecret, consumed.HandoffID, consumed.IdentityFingerprint,
|
||||
)
|
||||
if err := tenant.SyncPhone(
|
||||
phoneID,
|
||||
consumed.Name,
|
||||
controlplane.SHA256Hex(phoneToken),
|
||||
consumed.PhoneEd25519Public,
|
||||
consumed.PhoneX25519Public,
|
||||
); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "phone identity could not be created", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
if err := s.completePhoneHandoff(
|
||||
r.Context(), consumed.HandoffID, phoneID,
|
||||
controlplane.SHA256Hex(phoneToken), controlplane.SHA256Hex(routeHandle),
|
||||
); err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone handoff completion is indeterminate"))
|
||||
return
|
||||
}
|
||||
// Completion records only a pending principal. Do not cache the route yet:
|
||||
// the first request must prove possession to the control plane, which then
|
||||
// activates the principal and advances the authorization revision.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"phone_id": phoneID,
|
||||
"phone_token": phoneToken,
|
||||
"route_handle": routeHandle,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleDaemonWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// Daemons do not send browser Origin. If a caller does, require the same
|
||||
// exact allowlist as the PWA rather than accepting an arbitrary website.
|
||||
if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" {
|
||||
if _, allowed := s.origins[origin]; !allowed {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "origin is not allowed", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
}
|
||||
upgrader := s.upgrader
|
||||
upgrader.CheckOrigin = func(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
_, allowed := s.origins[origin]
|
||||
return allowed
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
first, firstType, firstFrame, err := readFirstFrame(conn, 10*time.Second)
|
||||
if err != nil || first.Type != protocol.MsgRegisterDevice {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "expected device authentication", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
deviceID, _ := first.Payload["device_id"].(string)
|
||||
token, _ := first.Payload["token"].(string)
|
||||
if strings.TrimSpace(deviceID) == "" || strings.TrimSpace(token) == "" {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "device credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolveDeviceRoute(r.Context(), deviceID, controlplane.SHA256Hex(token))
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "device route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardWebSocket(conn, r, route, firstType, firstFrame)
|
||||
return
|
||||
}
|
||||
tenant, err := s.authorizeDevice(r.Context(), deviceID, token, false)
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "device route is unavailable"))
|
||||
return
|
||||
}
|
||||
tenant.Engine().ServeDaemonConn(conn, first)
|
||||
}
|
||||
|
||||
func (s *Service) handlePhoneWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
first, firstType, firstFrame, err := readFirstFrame(conn, 15*time.Second)
|
||||
if err != nil || first.Type != protocol.MsgSubscribe {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "expected phone subscription", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
routeHandle := requestRouteHandle(r, first)
|
||||
phoneToken := requestPhoneToken(r, first)
|
||||
if routeHandle == "" || phoneToken == "" {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone route and credential are required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolvePhoneRoute(r.Context(), routeHandle, controlplane.SHA256Hex(phoneToken))
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardWebSocket(conn, r, route, firstType, firstFrame)
|
||||
return
|
||||
}
|
||||
tenant, err := s.authorizePhone(r.Context(), routeHandle, phoneToken, false)
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone route is unavailable"))
|
||||
return
|
||||
}
|
||||
cloned := r.Clone(r.Context())
|
||||
cloned.Header = r.Header.Clone()
|
||||
cloned.Header.Set("X-Neko-Phone-Token", phoneToken)
|
||||
tenant.Engine().ServePhoneConn(conn, cloned, first)
|
||||
}
|
||||
|
||||
func readFirstFrame(conn *websocket.Conn, timeout time.Duration) (*protocol.NekoMessage, int, []byte, error) {
|
||||
conn.SetReadLimit(maxFirstFrame)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
messageType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var message protocol.NekoMessage
|
||||
if err := json.Unmarshal(data, &message); err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
return &message, messageType, data, nil
|
||||
}
|
||||
|
||||
func (s *Service) forwardHTTP(w http.ResponseWriter, r *http.Request, route controlplane.RouteResolution) {
|
||||
if s.forwarder == nil || route.Local || route.EndpointRef == "" || route.RelayNodeID == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay route is unavailable", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
if err := s.forwarder.ForwardHTTP(w, r, route.EndpointRef, route.RelayNodeID); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay forwarding failed", true, http.StatusServiceUnavailable))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) forwardWebSocket(
|
||||
client *websocket.Conn, r *http.Request, route controlplane.RouteResolution,
|
||||
firstType int, firstFrame []byte,
|
||||
) {
|
||||
if s.forwarder == nil || route.Local || route.EndpointRef == "" || route.RelayNodeID == "" {
|
||||
writeWebSocketError(client, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay route is unavailable", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
target, response, err := s.forwarder.DialWebSocket(r.Context(), r, route.EndpointRef, route.RelayNodeID)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
writeWebSocketError(client, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay forwarding failed", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
defer target.Close()
|
||||
_ = forwarder.Tunnel(r.Context(), client, target, firstType, firstFrame)
|
||||
}
|
||||
|
||||
func (s *Service) authorizeDevice(ctx context.Context, deviceID, token string, allowCached bool) (*registry.Tenant, error) {
|
||||
digest := controlplane.SHA256Hex(token)
|
||||
response, err := s.controlPlane.AuthorizeDevice(ctx, strings.TrimSpace(deviceID), digest)
|
||||
if err != nil {
|
||||
if allowCached && controlUnavailable(err) {
|
||||
if tenant, ok := s.registry.ByDeviceID(deviceID); ok {
|
||||
return tenant, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := s.registry.Accept(ctx, response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
for _, device := range tenant.Payload().Devices {
|
||||
if device.DeviceID == deviceID && constantTimeEqual(device.CredentialHash, digest) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("authorized device missing from signed snapshot")
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *Service) authorizePhone(ctx context.Context, routeHandle, phoneToken string, allowCached bool) (*registry.Tenant, error) {
|
||||
tokenHash := controlplane.SHA256Hex(phoneToken)
|
||||
authorization, err := s.controlPlane.AuthorizePhone(ctx, routeHandle, tokenHash)
|
||||
if err != nil {
|
||||
if allowCached && controlUnavailable(err) {
|
||||
if tenant, ok := s.registry.ByPhoneRoute(routeHandle); ok {
|
||||
return tenant, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := s.controlPlane.AuthorizationSnapshot(ctx, authorization.TenantID, authorization.PlacementGeneration)
|
||||
if err != nil {
|
||||
if allowCached && controlUnavailable(err) {
|
||||
if tenant, ok := s.registry.ByPhoneRoute(routeHandle); ok {
|
||||
return tenant, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := s.registry.Accept(ctx, snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := tenant.Payload()
|
||||
if payload.TenantID != authorization.TenantID || payload.PlacementGeneration != authorization.PlacementGeneration {
|
||||
return nil, errors.New("phone authorization and signed placement disagree")
|
||||
}
|
||||
if err := tenant.SyncPhone(
|
||||
authorization.Phone.PhoneID, authorization.Phone.Name, tokenHash,
|
||||
authorization.Phone.Ed25519Public, authorization.Phone.X25519Public,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.registry.BindPhoneRoute(routeHandle, tenant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleDataPlane(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/attachments/") && r.Method == http.MethodGet {
|
||||
routeHint := strings.TrimSpace(r.URL.Query().Get("route"))
|
||||
if tenant, ok := s.registry.ByRouteHint(routeHint); ok {
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
tenantID, generation, err := s.registry.DecodeRouteHint(routeHint)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "attachment route is unavailable", false, http.StatusNotFound))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolveTenantRoute(r.Context(), tenantID, generation)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "attachment route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
snapshot, err := s.controlPlane.AuthorizationSnapshot(r.Context(), tenantID, generation)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "attachment route is unavailable"))
|
||||
return
|
||||
}
|
||||
tenant, err := s.registry.Accept(r.Context(), snapshot)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "attachment route could not be restored", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if routeHandle := strings.TrimSpace(r.Header.Get("X-Neko-Route-Handle")); routeHandle != "" {
|
||||
phoneToken := requestPhoneToken(r, nil)
|
||||
if phoneToken == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
if _, cached := s.registry.ByPhoneRoute(routeHandle); !cached {
|
||||
route, err := s.controlPlane.ResolvePhoneRoute(r.Context(), routeHandle, controlplane.SHA256Hex(phoneToken))
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorPhoneCredentialInvalid, "phone credential was rejected"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
}
|
||||
tenant, err := s.authorizePhone(r.Context(), routeHandle, phoneToken, true)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorPhoneCredentialInvalid, "phone credential was rejected"))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/phones/revoke" {
|
||||
s.handlePhoneRevoke(w, r, tenant, phoneToken)
|
||||
return
|
||||
}
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := readBounded(r.Body, maxRoutingBody)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "invalid device request", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
deviceID, token := deviceCredential(r, body)
|
||||
if deviceID == "" || token == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "device credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
if _, cached := s.registry.ByDeviceID(deviceID); !cached {
|
||||
route, err := s.controlPlane.ResolveDeviceRoute(r.Context(), deviceID, controlplane.SHA256Hex(token))
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorDeviceCredentialInvalid, "device credential was rejected"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
}
|
||||
tenant, err := s.authorizeDevice(r.Context(), deviceID, token, true)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorDeviceCredentialInvalid, "device credential was rejected"))
|
||||
return
|
||||
}
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Service) handlePhoneRevoke(w http.ResponseWriter, r *http.Request, tenant *registry.Tenant, _ string) {
|
||||
body, err := readBounded(r.Body, 16<<10)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "invalid phone revoke request", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
PhoneID string `json:"phone_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &request); err != nil || strings.TrimSpace(request.PhoneID) == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone_id is required", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
payload := tenant.Payload()
|
||||
// The Cloud data plane has no admin bypass. A route/token pair resolves one
|
||||
// phone principal, and the control plane enforces ownership before mutation.
|
||||
routeHandle := strings.TrimSpace(r.Header.Get("X-Neko-Route-Handle"))
|
||||
token := requestPhoneToken(r, nil)
|
||||
auth, err := s.controlPlane.AuthorizePhone(r.Context(), routeHandle, controlplane.SHA256Hex(token))
|
||||
if err != nil || auth.Phone.PhoneID != request.PhoneID {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone may only revoke itself", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
if err := s.controlPlane.RevokePhone(r.Context(), payload.TenantID, request.PhoneID, "phone self-revocation"); err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone revocation is indeterminate"))
|
||||
return
|
||||
}
|
||||
if err := tenant.RevokePhone(request.PhoneID); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "phone revocation is indeterminate", false, http.StatusConflict))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "revoked", "phone_id": request.PhoneID})
|
||||
}
|
||||
|
||||
func deviceCredential(r *http.Request, body []byte) (string, string) {
|
||||
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
|
||||
token := strings.TrimSpace(r.Header.Get("X-Neko-Device-Token"))
|
||||
if token == "" {
|
||||
token = bearerToken(r.Header.Get("Authorization"))
|
||||
}
|
||||
if len(body) > 0 && json.Valid(body) {
|
||||
var payload struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if json.Unmarshal(body, &payload) == nil {
|
||||
if deviceID == "" {
|
||||
deviceID = strings.TrimSpace(payload.DeviceID)
|
||||
}
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(payload.Token)
|
||||
}
|
||||
}
|
||||
}
|
||||
return deviceID, token
|
||||
}
|
||||
|
||||
func requestPhoneToken(r *http.Request, first *protocol.NekoMessage) string {
|
||||
for _, value := range []string{
|
||||
strings.TrimSpace(r.Header.Get("X-Neko-Phone-Token")),
|
||||
bearerToken(r.Header.Get("Authorization")),
|
||||
} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if first != nil && first.Payload != nil {
|
||||
if value, ok := first.Payload["phone_token"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requestRouteHandle(r *http.Request, first *protocol.NekoMessage) string {
|
||||
if value := strings.TrimSpace(r.Header.Get("X-Neko-Route-Handle")); value != "" {
|
||||
return value
|
||||
}
|
||||
if first != nil && first.Payload != nil {
|
||||
if value, ok := first.Payload["route_handle"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func bearerToken(value string) string {
|
||||
if len(value) > len("Bearer ") && strings.EqualFold(value[:len("Bearer ")], "Bearer ") {
|
||||
return strings.TrimSpace(value[len("Bearer "):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) sourceHash(r *http.Request) string {
|
||||
address := sourceAddress(r, s.proxies)
|
||||
mac := hmac.New(sha256.New, s.sourceSecret)
|
||||
_, _ = mac.Write([]byte("nekonest-cloud/registration-source/v1\x00" + address))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func sourceAddress(r *http.Request, trusted []netip.Prefix) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err != nil {
|
||||
host = strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
remote, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return "invalid"
|
||||
}
|
||||
remote = remote.Unmap()
|
||||
trustedProxy := false
|
||||
for _, prefix := range trusted {
|
||||
if prefix.Contains(remote) {
|
||||
trustedProxy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if trustedProxy {
|
||||
candidate := strings.TrimSpace(r.Header.Get("CF-Connecting-IP"))
|
||||
if candidate == "" {
|
||||
candidate = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
|
||||
}
|
||||
if forwarded, err := netip.ParseAddr(candidate); err == nil {
|
||||
return forwarded.Unmap().String()
|
||||
}
|
||||
}
|
||||
return remote.String()
|
||||
}
|
||||
|
||||
func deriveHandoffCredentials(secret []byte, handoffID, identityFingerprint string) (string, string, string) {
|
||||
derive := func(label string) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(label))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(handoffID))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(identityFingerprint))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
phoneDigest := derive("phone-id")
|
||||
return "phone_" + hex.EncodeToString(phoneDigest[:16]),
|
||||
hex.EncodeToString(derive("phone-token")),
|
||||
hex.EncodeToString(derive("route-handle"))
|
||||
}
|
||||
|
||||
func (s *Service) completePhoneHandoff(
|
||||
ctx context.Context, handoffID, phoneID, phoneTokenHash, routeHandleHash string,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt, delay := range []time.Duration{0, 100 * time.Millisecond, 300 * time.Millisecond} {
|
||||
if attempt > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return errors.Join(lastErr, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
lastErr = s.controlPlane.CompletePhoneHandoff(
|
||||
ctx, handoffID, phoneID, phoneTokenHash, routeHandleHash,
|
||||
)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
var remote *controlplane.RemoteError
|
||||
if errors.As(lastErr, &remote) && remote.Status < http.StatusInternalServerError && !remote.Body.Retryable {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func constantTimeEqual(left, right string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
|
||||
func controlUnavailable(err error) bool {
|
||||
var remote *controlplane.RemoteError
|
||||
return !errors.As(err, &remote)
|
||||
}
|
||||
|
||||
type serviceError struct {
|
||||
protocol.ServiceErrorPayload
|
||||
status int
|
||||
}
|
||||
|
||||
func apiError(code protocol.ServiceErrorCode, message string, retryable bool, status int) serviceError {
|
||||
return serviceError{ServiceErrorPayload: protocol.ServiceErrorPayload{
|
||||
ErrorCode: code, Message: message, Retryable: retryable,
|
||||
}, status: status}
|
||||
}
|
||||
|
||||
func publicError(err error, fallback protocol.ServiceErrorCode, message string) serviceError {
|
||||
var remote *controlplane.RemoteError
|
||||
if errors.As(err, &remote) && remote.Body.ErrorCode != "" {
|
||||
body := remote.Body
|
||||
if body.ActionURL != "" {
|
||||
parsed, parseErr := url.Parse(body.ActionURL)
|
||||
if parseErr != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
body.ActionURL = ""
|
||||
}
|
||||
}
|
||||
return serviceError{ServiceErrorPayload: body, status: remote.Status}
|
||||
}
|
||||
return serviceError{ServiceErrorPayload: protocol.ServiceErrorPayload{
|
||||
ErrorCode: fallback, Message: message, Retryable: true, RetryAfterSeconds: 5,
|
||||
}, status: http.StatusServiceUnavailable}
|
||||
}
|
||||
|
||||
func writeHTTPError(w http.ResponseWriter, err serviceError) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
status := err.status
|
||||
if status < 400 || status > 599 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(err.ServiceErrorPayload)
|
||||
}
|
||||
|
||||
func writeWebSocketError(conn *websocket.Conn, err serviceError) {
|
||||
_ = conn.WriteJSON(protocol.NekoMessage{
|
||||
ProtocolVersion: protocol.CurrentProtocolVersion,
|
||||
TransportMode: protocol.TransportSealed,
|
||||
Type: protocol.MsgError,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Payload: map[string]any{
|
||||
"error_code": err.ErrorCode,
|
||||
"message": err.Message,
|
||||
"retryable": err.Retryable,
|
||||
"retry_after_seconds": err.RetryAfterSeconds,
|
||||
"action_url": err.ActionURL,
|
||||
},
|
||||
})
|
||||
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, string(err.ErrorCode)), time.Now().Add(time.Second))
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/forwarder"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/registry"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
type fakeControlPlane struct {
|
||||
snapshot controlplane.SnapshotResponse
|
||||
register func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error)
|
||||
route *controlplane.RouteResolution
|
||||
consume func(context.Context, any) (controlplane.ConsumedHandoff, error)
|
||||
complete func(context.Context, string, string, string, string) error
|
||||
}
|
||||
|
||||
func localRoute() controlplane.RouteResolution {
|
||||
return controlplane.RouteResolution{
|
||||
RelayNodeID: "node_test", PlacementGeneration: 1,
|
||||
HomeRegion: "test", Local: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) ResolveDeviceRoute(context.Context, string, string) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
func (f *fakeControlPlane) ResolvePhoneRoute(context.Context, string, string) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
func (f *fakeControlPlane) ResolveTenantRoute(context.Context, string, int64) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
func (f *fakeControlPlane) ResolveHandoffRoute(context.Context, string, string) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) AuthorizeDevice(_ context.Context, _ string, _ string) (controlplane.SnapshotResponse, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
func (f *fakeControlPlane) AuthorizationSnapshot(_ context.Context, _ string, _ int64) (controlplane.SnapshotResponse, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
func (f *fakeControlPlane) AuthorizationDelta(_ context.Context, _ string, _ int64) (controlplane.DeltaResponse, error) {
|
||||
return controlplane.DeltaResponse{}, nil
|
||||
}
|
||||
func (f *fakeControlPlane) AuthorizePhone(context.Context, string, string) (controlplane.PhoneAuthorization, error) {
|
||||
return controlplane.PhoneAuthorization{}, nil
|
||||
}
|
||||
func (f *fakeControlPlane) ConsumePhoneHandoff(ctx context.Context, request any) (controlplane.ConsumedHandoff, error) {
|
||||
if f.consume != nil {
|
||||
return f.consume(ctx, request)
|
||||
}
|
||||
return controlplane.ConsumedHandoff{}, nil
|
||||
}
|
||||
func (f *fakeControlPlane) CompletePhoneHandoff(ctx context.Context, handoffID, phoneID, phoneTokenHash, routeHandleHash string) error {
|
||||
if f.complete != nil {
|
||||
return f.complete(ctx, handoffID, phoneID, phoneTokenHash, routeHandleHash)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeControlPlane) RevokePhone(context.Context, string, string, string) error { return nil }
|
||||
func (f *fakeControlPlane) RegisterDevice(_ context.Context, bootstrap, source string, request json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return f.register(bootstrap, source, request)
|
||||
}
|
||||
|
||||
func signedFixture(t *testing.T, token string) (controlplane.SnapshotResponse, authsnapshot.Keyring) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
keyString := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
payload := authsnapshot.Payload{
|
||||
SnapshotVersion: 1,
|
||||
TenantID: "tenant_0123456789abcdef0123456789abcdef",
|
||||
TenantStatus: "active",
|
||||
HomeRegion: "test",
|
||||
RelayNodeID: "node_test",
|
||||
PlacementGeneration: 1,
|
||||
AuthorizationRevision: 1,
|
||||
Devices: []authsnapshot.Device{{
|
||||
DeviceID: "host_0123456789abcdef0123456789abcdef",
|
||||
CredentialHash: controlplane.SHA256Hex(token),
|
||||
IdentityFingerprint: strings.Repeat("a", 64),
|
||||
Ed25519Public: keyString,
|
||||
X25519Public: keyString,
|
||||
Name: "Test host",
|
||||
OS: "windows",
|
||||
}},
|
||||
IssuedAt: now.Add(-time.Second).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(4 * time.Minute).Format(time.RFC3339Nano),
|
||||
}
|
||||
message, err := authsnapshot.SigningBytes(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed := authsnapshot.Signed{
|
||||
Algorithm: "Ed25519",
|
||||
KID: "test-key",
|
||||
Payload: payload,
|
||||
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, message)),
|
||||
}
|
||||
jwk, _ := json.Marshal(map[string]string{
|
||||
"kty": "OKP", "crv": "Ed25519", "kid": "test-key",
|
||||
"x": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
})
|
||||
return controlplane.SnapshotResponse{Snapshot: signed, PublicKeyJWK: jwk}, authsnapshot.Keyring{
|
||||
"test-key": {PublicKey: publicKey, NotBefore: now.Add(-time.Hour), RetainUntil: now.Add(time.Hour)},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, control *fakeControlPlane, keyring authsnapshot.Keyring) *Service {
|
||||
return newTestServiceWithNode(t, control, keyring, "node_test", nil)
|
||||
}
|
||||
|
||||
func newTestServiceWithNode(
|
||||
t *testing.T, control *fakeControlPlane, keyring authsnapshot.Keyring,
|
||||
nodeID string, relayForwarder *forwarder.Forwarder,
|
||||
) *Service {
|
||||
t.Helper()
|
||||
registryValue, err := registry.New(registry.Config{
|
||||
DataRoot: t.TempDir(),
|
||||
NodeID: nodeID,
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: keyring,
|
||||
ControlPlane: control,
|
||||
RefreshInterval: time.Hour,
|
||||
DeltaInterval: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = registryValue.Close() })
|
||||
service, err := New(Config{
|
||||
ControlPlane: control,
|
||||
Registry: registryValue,
|
||||
AllowedPWAOrigins: []string{"https://pwa.example"},
|
||||
SourceHashSecret: []byte("abcdef0123456789abcdef0123456789"),
|
||||
NodeID: nodeID,
|
||||
ForwardSecret: []byte("fedcba9876543210fedcba9876543210"),
|
||||
HandoffSecret: []byte("handoff0123456789handoff0123456789"),
|
||||
Forwarder: relayForwarder,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestRegistrationHashesTrustedSourceAndReturnsStableContract(t *testing.T) {
|
||||
snapshot, keys := signedFixture(t, "device-token")
|
||||
control := &fakeControlPlane{snapshot: snapshot}
|
||||
control.register = func(bootstrap, source string, body json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
if bootstrap != "pair_secret" || len(source) != 64 {
|
||||
t.Fatalf("bootstrap=%q source=%q", bootstrap, source)
|
||||
}
|
||||
if strings.Contains(string(body), "pair_secret") {
|
||||
t.Fatal("relay injected bootstrap into the client body before the trusted control call")
|
||||
}
|
||||
return protocol.DeviceRegistrationResponse{
|
||||
DeviceID: "host_0123456789abcdef0123456789abcdef",
|
||||
Token: "device-token",
|
||||
Name: "Host",
|
||||
TransportMode: protocol.TransportSealed,
|
||||
ConnectionState: protocol.ConnectionReady,
|
||||
}, nil
|
||||
}
|
||||
service := newTestService(t, control, keys)
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/devices/register", strings.NewReader(`{"os":"windows"}`))
|
||||
request.RemoteAddr = "203.0.113.5:12345"
|
||||
request.Header.Set("X-Neko-Bootstrap", "pair_secret")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"connection_state":"ready"`) {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceAddressOnlyTrustsForwardedIPFromConfiguredProxy(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/devices/register", nil)
|
||||
request.RemoteAddr = "192.0.2.10:443"
|
||||
request.Header.Set("CF-Connecting-IP", "203.0.113.9")
|
||||
if got := sourceAddress(request, nil); got != "192.0.2.10" {
|
||||
t.Fatalf("untrusted source = %q", got)
|
||||
}
|
||||
prefix := netip.MustParsePrefix("192.0.2.0/24")
|
||||
if got := sourceAddress(request, []netip.Prefix{prefix}); got != "203.0.113.9" {
|
||||
t.Fatalf("trusted source = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneHandoffRejectsTrailingJSON(t *testing.T) {
|
||||
snapshot, keys := signedFixture(t, "device-token")
|
||||
control := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
service := newTestService(t, control, keys)
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/pwa/handoff/exchange", strings.NewReader(
|
||||
`{"ticket":"ticket","pwa_origin":"https://pwa.example"} {}`,
|
||||
))
|
||||
request.Header.Set("Origin", "https://pwa.example")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneHandoffRetriesAmbiguousCompletionWithStableCredentials(t *testing.T) {
|
||||
snapshot, keys := signedFixture(t, "device-token")
|
||||
key := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
control := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
control.consume = func(context.Context, any) (controlplane.ConsumedHandoff, error) {
|
||||
return controlplane.ConsumedHandoff{
|
||||
HandoffID: "handoff_0123456789abcdef0123456789abcdef",
|
||||
TenantID: snapshot.Snapshot.Payload.TenantID,
|
||||
Name: "Cloud PWA",
|
||||
PhoneEd25519Public: key,
|
||||
PhoneX25519Public: key,
|
||||
IdentityFingerprint: strings.Repeat("f", 64),
|
||||
PlacementGeneration: snapshot.Snapshot.Payload.PlacementGeneration,
|
||||
}, nil
|
||||
}
|
||||
type completion struct{ handoff, phone, tokenHash, routeHash string }
|
||||
var completions []completion
|
||||
control.complete = func(_ context.Context, handoff, phone, tokenHash, routeHash string) error {
|
||||
completions = append(completions, completion{handoff, phone, tokenHash, routeHash})
|
||||
if len(completions) == 1 {
|
||||
return errors.New("control-plane response lost after commit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
service := newTestService(t, control, keys)
|
||||
body := `{"ticket":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","pwa_origin":"https://pwa.example","name":"Cloud PWA","phone_ed25519_public":"` + key + `","phone_x25519_public":"` + key + `","identity_fingerprint":"` + strings.Repeat("f", 64) + `"}`
|
||||
exchange := func() map[string]string {
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/pwa/handoff/exchange", strings.NewReader(body))
|
||||
request.Header.Set("Origin", "https://pwa.example")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
first := exchange()
|
||||
second := exchange()
|
||||
if len(completions) != 3 || completions[0] != completions[1] || completions[1] != completions[2] {
|
||||
t.Fatalf("completion credentials changed across retry: %#v", completions)
|
||||
}
|
||||
if first["phone_id"] != second["phone_id"] || first["phone_token"] != second["phone_token"] || first["route_handle"] != second["route_handle"] {
|
||||
t.Fatalf("handoff response changed across retry: first=%#v second=%#v", first, second)
|
||||
}
|
||||
if _, cached := service.registry.ByPhoneRoute(first["route_handle"]); cached {
|
||||
t.Fatal("pending handoff route became usable before first possession proof")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondLiveDaemonWithSameIdentityIsRejected(t *testing.T) {
|
||||
const token = "device-token"
|
||||
snapshot, keys := signedFixture(t, token)
|
||||
control := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
service := newTestService(t, control, keys)
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
|
||||
dial := func() *websocket.Conn {
|
||||
connection, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"/ws/daemon", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.WriteJSON(protocol.NekoMessage{
|
||||
ProtocolVersion: "1.3",
|
||||
TransportMode: protocol.TransportSealed,
|
||||
Type: protocol.MsgRegisterDevice,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Payload: map[string]any{
|
||||
"device_id": "host_0123456789abcdef0123456789abcdef",
|
||||
"token": token,
|
||||
"daemon_version": "0.2.6",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return connection
|
||||
}
|
||||
first := dial()
|
||||
defer first.Close()
|
||||
var authenticated protocol.NekoMessage
|
||||
if err := first.ReadJSON(&authenticated); err != nil || authenticated.Type != protocol.MsgAuthResponse {
|
||||
t.Fatalf("first authentication err=%v message=%+v", err, authenticated)
|
||||
}
|
||||
second := dial()
|
||||
defer second.Close()
|
||||
var rejected protocol.NekoMessage
|
||||
if err := second.ReadJSON(&rejected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rejected.Type != protocol.MsgError || rejected.Payload["error_code"] != string(protocol.ServiceErrorDeviceAlreadyConnected) {
|
||||
t.Fatalf("rejected = %+v", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteIngressTunnelsDaemonWithoutRedirectingClient(t *testing.T) {
|
||||
const token = "device-token"
|
||||
snapshot, keys := signedFixture(t, token)
|
||||
targetControl := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
targetService := newTestServiceWithNode(t, targetControl, keys, "node_test", nil)
|
||||
targetServer := httptest.NewServer(targetService.Handler())
|
||||
defer targetServer.Close()
|
||||
|
||||
relayForwarder, err := forwarder.New(forwarder.Config{
|
||||
NodeID: "node_source", Secret: []byte("fedcba9876543210fedcba9876543210"),
|
||||
Endpoints: map[string]string{"target-ref": targetServer.URL},
|
||||
HTTPClient: targetServer.Client(), WebSocketDialer: websocket.DefaultDialer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteRoute := controlplane.RouteResolution{
|
||||
RelayNodeID: "node_test", PlacementGeneration: 1, HomeRegion: "test",
|
||||
Local: false, EndpointRef: "target-ref",
|
||||
}
|
||||
sourceControl := &fakeControlPlane{snapshot: snapshot, route: &remoteRoute, register: targetControl.register}
|
||||
sourceService := newTestServiceWithNode(t, sourceControl, keys, "node_source", relayForwarder)
|
||||
sourceServer := httptest.NewServer(sourceService.Handler())
|
||||
defer sourceServer.Close()
|
||||
|
||||
connection, response, err := websocket.DefaultDialer.Dial(
|
||||
"ws"+strings.TrimPrefix(sourceServer.URL, "http")+"/ws/daemon", nil,
|
||||
)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := connection.WriteJSON(protocol.NekoMessage{
|
||||
ProtocolVersion: "1.3", TransportMode: protocol.TransportSealed,
|
||||
Type: protocol.MsgRegisterDevice, Timestamp: time.Now().Unix(),
|
||||
Payload: map[string]any{
|
||||
"device_id": "host_0123456789abcdef0123456789abcdef",
|
||||
"token": token, "daemon_version": "0.2.6",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var authenticated protocol.NekoMessage
|
||||
if err := connection.ReadJSON(&authenticated); err != nil || authenticated.Type != protocol.MsgAuthResponse {
|
||||
t.Fatalf("forwarded authentication err=%v message=%+v", err, authenticated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
package tenantbackup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const manifestVersion = 1
|
||||
|
||||
type File struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Version int `json:"version"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Files []File `json:"files"`
|
||||
}
|
||||
|
||||
type restoreReceipt struct {
|
||||
Version int `json:"version"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
PlacementGeneration int64 `json:"placement_generation"`
|
||||
ManifestSHA256 string `json:"manifest_sha256"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Path string
|
||||
BackupRef string
|
||||
Manifest Manifest
|
||||
ManifestSHA256 string
|
||||
}
|
||||
|
||||
func validBackupRef(value string) bool {
|
||||
parts := strings.Split(filepath.ToSlash(value), "/")
|
||||
if len(parts) != 2 || len(parts[0]) != 32 || !strings.HasPrefix(parts[1], "g") || strings.Contains(parts[1], ".tmp") {
|
||||
return false
|
||||
}
|
||||
for _, char := range parts[0] {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(parts[1]) != len("g00000000000000000000-20060102T150405Z-0000000000000000") {
|
||||
return false
|
||||
}
|
||||
for index, char := range parts[1] {
|
||||
switch index {
|
||||
case 0:
|
||||
if char != 'g' {
|
||||
return false
|
||||
}
|
||||
case 21, 38:
|
||||
if char != '-' {
|
||||
return false
|
||||
}
|
||||
case 30:
|
||||
if char != 'T' {
|
||||
return false
|
||||
}
|
||||
case 37:
|
||||
if char != 'Z' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if index >= 39 {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
} else if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveReference turns an opaque control-plane backup reference into a
|
||||
// local immutable backup path without accepting an absolute path or symlink.
|
||||
func ResolveReference(backupRoot, reference string) (string, error) {
|
||||
if !validBackupRef(reference) {
|
||||
return "", fmt.Errorf("invalid backup reference")
|
||||
}
|
||||
root, err := filepath.Abs(strings.TrimSpace(backupRoot))
|
||||
if err != nil || strings.TrimSpace(backupRoot) == "" {
|
||||
return "", fmt.Errorf("invalid backup root")
|
||||
}
|
||||
if info, err := os.Lstat(root); err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return "", fmt.Errorf("backup root is not a real directory")
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(reference), "/")
|
||||
parent := filepath.Join(root, parts[0])
|
||||
if info, err := os.Lstat(parent); err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return "", fmt.Errorf("backup tenant directory is not real")
|
||||
}
|
||||
resolved := filepath.Join(parent, parts[1])
|
||||
if filepath.Dir(resolved) != parent {
|
||||
return "", fmt.Errorf("backup reference escaped root")
|
||||
}
|
||||
if info, err := os.Lstat(resolved); err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return "", fmt.Errorf("backup reference is unavailable")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func randomHex(size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func ensureRealDirectory(path string) error {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a real directory", path)
|
||||
}
|
||||
return os.Chmod(path, 0o700)
|
||||
}
|
||||
|
||||
func regularFile(path string) (os.FileInfo, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func validAttachmentName(name string) bool {
|
||||
stem := ""
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".bin"):
|
||||
stem = strings.TrimSuffix(name, ".bin")
|
||||
case strings.HasSuffix(name, ".json"):
|
||||
stem = strings.TrimSuffix(name, ".json")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if len(stem) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range stem {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func checkpointAndVerify(path string) error {
|
||||
if _, err := regularFile(path); err != nil {
|
||||
return fmt.Errorf("inspect sqlite database: %w", err)
|
||||
}
|
||||
database, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
var busy, logFrames, checkpointed int
|
||||
if err := database.QueryRow(`PRAGMA wal_checkpoint(TRUNCATE)`).Scan(&busy, &logFrames, &checkpointed); err != nil {
|
||||
return fmt.Errorf("checkpoint sqlite: %w", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
return fmt.Errorf("sqlite checkpoint remained busy")
|
||||
}
|
||||
var result string
|
||||
if err := database.QueryRow(`PRAGMA integrity_check`).Scan(&result); err != nil {
|
||||
return fmt.Errorf("check sqlite integrity: %w", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
return fmt.Errorf("sqlite integrity check failed: %s", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifySQLiteReadOnly(path string) error {
|
||||
if _, err := regularFile(path); err != nil {
|
||||
return fmt.Errorf("inspect sqlite database: %w", err)
|
||||
}
|
||||
database, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&immutable=1&_pragma=query_only(1)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
var result string
|
||||
if err := database.QueryRow(`PRAGMA integrity_check`).Scan(&result); err != nil {
|
||||
return fmt.Errorf("check restored sqlite integrity: %w", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
return fmt.Errorf("restored sqlite integrity check failed: %s", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashFile(ctx context.Context, path string) (int64, string, error) {
|
||||
if _, err := regularFile(path); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
buffer := make([]byte, 128<<10)
|
||||
var total int64
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
count, readErr := file.Read(buffer)
|
||||
if count > 0 {
|
||||
total += int64(count)
|
||||
_, _ = hash.Write(buffer[:count])
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return 0, "", readErr
|
||||
}
|
||||
}
|
||||
return total, hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func copyFile(ctx context.Context, source, destination string) (File, error) {
|
||||
if _, err := regularFile(source); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
input, err := os.Open(source)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
defer input.Close()
|
||||
if err := ensureRealDirectory(filepath.Dir(destination)); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
remove := true
|
||||
defer func() {
|
||||
_ = output.Close()
|
||||
if remove {
|
||||
_ = os.Remove(destination)
|
||||
}
|
||||
}()
|
||||
hash := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(output, hash), &contextReader{ctx: ctx, reader: input})
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
if err := output.Sync(); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
remove = false
|
||||
return File{Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
|
||||
}
|
||||
|
||||
type contextReader struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (reader *contextReader) Read(buffer []byte) (int, error) {
|
||||
if err := reader.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return reader.reader.Read(buffer)
|
||||
}
|
||||
|
||||
func sourceFiles(paths tenantfs.Paths) ([]struct{ absolute, relative string }, error) {
|
||||
if _, err := regularFile(paths.Database); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachmentInfo, err := os.Lstat(paths.Attachments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attachmentInfo.Mode()&os.ModeSymlink != 0 || !attachmentInfo.IsDir() {
|
||||
return nil, fmt.Errorf("attachment root is not a real directory")
|
||||
}
|
||||
result := []struct{ absolute, relative string }{{paths.Database, "relay.db"}}
|
||||
entries, err := os.ReadDir(paths.Attachments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !validAttachmentName(entry.Name()) {
|
||||
return nil, fmt.Errorf("invalid attachment artifact %q", entry.Name())
|
||||
}
|
||||
result = append(result, struct{ absolute, relative string }{
|
||||
filepath.Join(paths.Attachments, entry.Name()), filepath.ToSlash(filepath.Join("attachments", entry.Name())),
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool { return result[left].relative < result[right].relative })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Create(ctx context.Context, dataRoot, backupRoot, tenantID string, generation int64, now time.Time) (Result, error) {
|
||||
if generation < 1 || now.IsZero() {
|
||||
return Result{}, fmt.Errorf("invalid backup generation or timestamp")
|
||||
}
|
||||
paths, err := tenantfs.Derive(dataRoot, tenantID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := checkpointAndVerify(paths.Database); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
files, err := sourceFiles(paths)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
backupRoot, err = filepath.Abs(strings.TrimSpace(backupRoot))
|
||||
if err != nil || strings.TrimSpace(backupRoot) == "" {
|
||||
return Result{}, fmt.Errorf("invalid backup root")
|
||||
}
|
||||
if err := ensureRealDirectory(backupRoot); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
tenantBackupRoot := filepath.Join(backupRoot, filepath.Base(paths.Root))
|
||||
if err := ensureRealDirectory(tenantBackupRoot); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
nonce, err := randomHex(8)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
name := fmt.Sprintf("g%020d-%s-%s", generation, now.UTC().Format("20060102T150405Z"), nonce)
|
||||
finalPath := filepath.Join(tenantBackupRoot, name)
|
||||
stagingPath := finalPath + ".tmp"
|
||||
if err := os.Mkdir(stagingPath, 0o700); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = os.RemoveAll(stagingPath)
|
||||
}
|
||||
}()
|
||||
manifest := Manifest{
|
||||
Version: manifestVersion, TenantID: tenantID, PlacementGeneration: generation,
|
||||
CreatedAt: now.UTC().Format(time.RFC3339Nano), Files: make([]File, 0, len(files)),
|
||||
}
|
||||
for _, item := range files {
|
||||
copied, err := copyFile(ctx, item.absolute, filepath.Join(stagingPath, filepath.FromSlash(item.relative)))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
copied.Path = item.relative
|
||||
manifest.Files = append(manifest.Files, copied)
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
manifestPath := filepath.Join(stagingPath, "manifest.json")
|
||||
manifestFile, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if _, err := manifestFile.Write(manifestBytes); err != nil {
|
||||
_ = manifestFile.Close()
|
||||
return Result{}, err
|
||||
}
|
||||
if err := manifestFile.Sync(); err != nil {
|
||||
_ = manifestFile.Close()
|
||||
return Result{}, err
|
||||
}
|
||||
if err := manifestFile.Close(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := os.Rename(stagingPath, finalPath); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
committed = true
|
||||
digest := sha256.Sum256(manifestBytes)
|
||||
backupRef := filepath.ToSlash(filepath.Join(filepath.Base(paths.Root), name))
|
||||
if !validBackupRef(backupRef) {
|
||||
return Result{}, fmt.Errorf("generated backup reference is invalid")
|
||||
}
|
||||
return Result{Path: finalPath, BackupRef: backupRef, Manifest: manifest, ManifestSHA256: hex.EncodeToString(digest[:])}, nil
|
||||
}
|
||||
|
||||
func validManifestPath(path string) bool {
|
||||
if path == "relay.db" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
return len(parts) == 2 && parts[0] == "attachments" && validAttachmentName(parts[1])
|
||||
}
|
||||
|
||||
func Verify(ctx context.Context, backupPath, tenantID string, generation int64, expectedManifestSHA256 string) (Manifest, error) {
|
||||
backupPath, err := filepath.Abs(strings.TrimSpace(backupPath))
|
||||
if err != nil || strings.TrimSpace(backupPath) == "" {
|
||||
return Manifest{}, fmt.Errorf("invalid backup path")
|
||||
}
|
||||
info, err := os.Lstat(backupPath)
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return Manifest{}, fmt.Errorf("backup path is not a real directory")
|
||||
}
|
||||
manifestPath := filepath.Join(backupPath, "manifest.json")
|
||||
manifestInfo, err := regularFile(manifestPath)
|
||||
if err != nil || manifestInfo.Size() > 1<<20 {
|
||||
return Manifest{}, fmt.Errorf("invalid backup manifest")
|
||||
}
|
||||
manifestBytes, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
digest := sha256.Sum256(manifestBytes)
|
||||
if expectedManifestSHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Manifest{}, fmt.Errorf("backup manifest digest mismatch")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(manifestBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
var manifest Manifest
|
||||
if err := decoder.Decode(&manifest); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return Manifest{}, fmt.Errorf("invalid backup manifest JSON")
|
||||
}
|
||||
if manifest.Version != manifestVersion || manifest.TenantID != tenantID || manifest.PlacementGeneration != generation || len(manifest.Files) == 0 {
|
||||
return Manifest{}, fmt.Errorf("backup manifest fence mismatch")
|
||||
}
|
||||
expectedFiles := map[string]struct{}{"manifest.json": {}}
|
||||
previous := ""
|
||||
for _, item := range manifest.Files {
|
||||
if !validManifestPath(item.Path) || item.Path <= previous || item.Size < 0 || len(item.SHA256) != 64 {
|
||||
return Manifest{}, fmt.Errorf("invalid backup file manifest")
|
||||
}
|
||||
previous = item.Path
|
||||
absolute := filepath.Join(backupPath, filepath.FromSlash(item.Path))
|
||||
if !strings.HasPrefix(absolute, backupPath+string(os.PathSeparator)) {
|
||||
return Manifest{}, fmt.Errorf("backup file escaped root")
|
||||
}
|
||||
size, checksum, err := hashFile(ctx, absolute)
|
||||
if err != nil || size != item.Size || checksum != item.SHA256 {
|
||||
return Manifest{}, fmt.Errorf("backup file verification failed for %s", item.Path)
|
||||
}
|
||||
expectedFiles[filepath.Clean(item.Path)] = struct{}{}
|
||||
}
|
||||
err = filepath.WalkDir(backupPath, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == backupPath {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(backupPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("backup contains a symbolic link")
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if filepath.Clean(relative) != "attachments" {
|
||||
return fmt.Errorf("backup contains an unexpected directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, ok := expectedFiles[filepath.Clean(relative)]; !ok {
|
||||
return fmt.Errorf("backup contains an unexpected file %q", relative)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
if err := verifySQLiteReadOnly(filepath.Join(backupPath, "relay.db")); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func Restore(ctx context.Context, backupPath, dataRoot, tenantID string, generation int64, manifestSHA256 string) (tenantfs.Paths, error) {
|
||||
manifest, err := Verify(ctx, backupPath, tenantID, generation, manifestSHA256)
|
||||
if err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
paths, err := tenantfs.Derive(dataRoot, tenantID)
|
||||
if err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
dataRoot = filepath.Dir(filepath.Dir(paths.Root))
|
||||
if err := ensureRealDirectory(dataRoot); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
tenantsRoot := filepath.Dir(paths.Root)
|
||||
if err := ensureRealDirectory(tenantsRoot); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
if info, err := os.Lstat(paths.Root); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return tenantfs.Paths{}, fmt.Errorf("target tenant path is not a real directory")
|
||||
}
|
||||
receiptBytes, readErr := os.ReadFile(filepath.Join(paths.Root, ".restore-receipt.json"))
|
||||
var receipt restoreReceipt
|
||||
decoder := json.NewDecoder(bytes.NewReader(receiptBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
if readErr == nil && decoder.Decode(&receipt) == nil && decoder.Decode(&struct{}{}) == io.EOF &&
|
||||
receipt.Version == manifestVersion && receipt.TenantID == tenantID &&
|
||||
receipt.PlacementGeneration == generation && receipt.ManifestSHA256 == manifestSHA256 {
|
||||
return paths, nil
|
||||
}
|
||||
return tenantfs.Paths{}, fmt.Errorf("target tenant directory already exists with another restore fence")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
nonce, err := randomHex(8)
|
||||
if err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
staging := filepath.Join(tenantsRoot, ".restore-"+filepath.Base(paths.Root)+"-"+nonce)
|
||||
if err := os.Mkdir(staging, 0o700); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = os.RemoveAll(staging)
|
||||
}
|
||||
}()
|
||||
for _, item := range manifest.Files {
|
||||
if _, err := copyFile(ctx, filepath.Join(backupPath, filepath.FromSlash(item.Path)), filepath.Join(staging, filepath.FromSlash(item.Path))); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
}
|
||||
if err := verifySQLiteReadOnly(filepath.Join(staging, "relay.db")); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
receiptBytes, err := json.Marshal(restoreReceipt{
|
||||
Version: manifestVersion, TenantID: tenantID,
|
||||
PlacementGeneration: generation, ManifestSHA256: manifestSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
receipt, err := os.OpenFile(filepath.Join(staging, ".restore-receipt.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
if _, err := receipt.Write(receiptBytes); err != nil {
|
||||
_ = receipt.Close()
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
if err := receipt.Sync(); err != nil {
|
||||
_ = receipt.Close()
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
if err := receipt.Close(); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
if err := os.Rename(staging, paths.Root); err != nil {
|
||||
return tenantfs.Paths{}, err
|
||||
}
|
||||
committed = true
|
||||
return paths, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package tenantbackup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantstore"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
func TestCreateVerifyAndRestoreTenantBackup(t *testing.T) {
|
||||
const tenantID = "tenant_0123456789abcdef0123456789abcdef"
|
||||
sourceRoot := t.TempDir()
|
||||
paths, err := tenantfs.Resolve(sourceRoot, tenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := tenantstore.NewWithTransportMode(paths.Database, string(protocol.TransportSealed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := store.RegisterDevice("host_0123456789abcdef0123456789abcdef", "Host", "linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachmentID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
if err := os.WriteFile(filepath.Join(paths.Attachments, attachmentID+".bin"), []byte("sealed payload"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(paths.Attachments, attachmentID+".json"), []byte(`{"id":"`+attachmentID+`"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := Create(context.Background(), sourceRoot, t.TempDir(), tenantID, 7, time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Verify(context.Background(), result.Path, tenantID, 7, result.ManifestSHA256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved, err := ResolveReference(filepath.Dir(filepath.Dir(result.Path)), result.BackupRef); err != nil || resolved != result.Path {
|
||||
t.Fatalf("backup reference did not resolve: %q err=%v", resolved, err)
|
||||
}
|
||||
targetRoot := t.TempDir()
|
||||
restored, err := Restore(context.Background(), result.Path, targetRoot, tenantID, 7, result.ManifestSHA256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retried, err := Restore(context.Background(), result.Path, targetRoot, tenantID, 7, result.ManifestSHA256); err != nil || retried.Root != restored.Root {
|
||||
t.Fatalf("idempotent restore failed: %#v err=%v", retried, err)
|
||||
}
|
||||
restoredStore, err := tenantstore.NewWithTransportMode(restored.Database, string(protocol.TransportSealed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer restoredStore.Close()
|
||||
if !restoredStore.ValidateDeviceToken("host_0123456789abcdef0123456789abcdef", token) {
|
||||
t.Fatal("restored database lost the device credential")
|
||||
}
|
||||
payload, err := os.ReadFile(filepath.Join(restored.Attachments, attachmentID+".bin"))
|
||||
if err != nil || string(payload) != "sealed payload" {
|
||||
t.Fatalf("restored attachment = %q err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperedBackup(t *testing.T) {
|
||||
const tenantID = "tenant_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
root := t.TempDir()
|
||||
paths, err := tenantfs.Resolve(root, tenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := tenantstore.NewWithTransportMode(paths.Database, string(protocol.TransportSealed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := Create(context.Background(), root, t.TempDir(), tenantID, 1, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.OpenFile(filepath.Join(result.Path, "relay.db"), os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = file.Write([]byte("tamper"))
|
||||
_ = file.Close()
|
||||
if _, err := Verify(context.Background(), result.Path, tenantID, 1, result.ManifestSHA256); err == nil {
|
||||
t.Fatal("tampered backup was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package tenantfs
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Paths struct {
|
||||
Root string
|
||||
Database string
|
||||
Attachments string
|
||||
}
|
||||
|
||||
func validTenantID(id string) bool {
|
||||
if !strings.HasPrefix(id, "tenant_") || len(id) != len("tenant_")+32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range strings.TrimPrefix(id, "tenant_") {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ensureRealDirectory(path string) error {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(path, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a real directory", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Derive maps an internal tenant UUID to non-client-visible paths without
|
||||
// creating them. Restore tooling uses this to stage an atomic directory swap.
|
||||
func Derive(dataRoot, tenantID string) (Paths, error) {
|
||||
if !validTenantID(tenantID) {
|
||||
return Paths{}, fmt.Errorf("invalid tenant id")
|
||||
}
|
||||
root, err := filepath.Abs(strings.TrimSpace(dataRoot))
|
||||
if err != nil || strings.TrimSpace(dataRoot) == "" {
|
||||
return Paths{}, fmt.Errorf("invalid data root")
|
||||
}
|
||||
tenantsRoot := filepath.Join(root, "tenants")
|
||||
digest := sha256.Sum256([]byte("nekonest-cloud/tenant-directory/v1\x00" + tenantID))
|
||||
directoryName := hex.EncodeToString(digest[:16])
|
||||
tenantRoot := filepath.Join(tenantsRoot, directoryName)
|
||||
if filepath.Dir(tenantRoot) != tenantsRoot {
|
||||
return Paths{}, fmt.Errorf("tenant path escaped root")
|
||||
}
|
||||
attachments := filepath.Join(tenantRoot, "attachments")
|
||||
return Paths{
|
||||
Root: tenantRoot,
|
||||
Database: filepath.Join(tenantRoot, "relay.db"),
|
||||
Attachments: attachments,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resolve derives and creates the secure directory tree for a live Engine.
|
||||
// The caller still carries tenant ID separately for authorization and fencing;
|
||||
// filesystem paths never accept client-controlled path fragments.
|
||||
func Resolve(dataRoot, tenantID string) (Paths, error) {
|
||||
paths, err := Derive(dataRoot, tenantID)
|
||||
if err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
root := filepath.Dir(filepath.Dir(paths.Root))
|
||||
if err := ensureRealDirectory(root); err != nil {
|
||||
return Paths{}, fmt.Errorf("prepare data root: %w", err)
|
||||
}
|
||||
if err := ensureRealDirectory(filepath.Dir(paths.Root)); err != nil {
|
||||
return Paths{}, fmt.Errorf("prepare tenants root: %w", err)
|
||||
}
|
||||
if err := ensureRealDirectory(paths.Root); err != nil {
|
||||
return Paths{}, fmt.Errorf("prepare tenant root: %w", err)
|
||||
}
|
||||
if err := ensureRealDirectory(paths.Attachments); err != nil {
|
||||
return Paths{}, fmt.Errorf("prepare attachment root: %w", err)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package tenantfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveUsesOpaqueTenantDirectoryAndRejectsTraversal(t *testing.T) {
|
||||
tenantID := "tenant_0123456789abcdef0123456789abcdef"
|
||||
paths, err := Resolve(t.TempDir(), tenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(paths.Root, tenantID) || filepath.Base(paths.Root) == tenantID {
|
||||
t.Fatalf("tenant id leaked into path: %s", paths.Root)
|
||||
}
|
||||
if filepath.Dir(paths.Attachments) != paths.Root || filepath.Dir(paths.Database) != paths.Root {
|
||||
t.Fatalf("paths escaped tenant root: %#v", paths)
|
||||
}
|
||||
if _, err := Resolve(t.TempDir(), "tenant_../../escape"); err == nil {
|
||||
t.Fatal("traversal tenant id was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveDoesNotCreateTenantDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths, err := Derive(root, "tenant_0123456789abcdef0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(paths.Root); !os.IsNotExist(err) {
|
||||
t.Fatalf("Derive created tenant root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsSymlinkedTenantRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tenantID := "tenant_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
paths, err := Resolve(root, tenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(paths.Attachments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(paths.Root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := t.TempDir()
|
||||
if err := os.Symlink(target, paths.Root); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
if _, err := Resolve(root, tenantID); err == nil {
|
||||
t.Fatal("symlinked tenant root was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tenantpurge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
DataDeleted bool
|
||||
BackupsDeleted bool
|
||||
EvidenceSHA256 string
|
||||
}
|
||||
|
||||
func exactChild(parent, child string) (string, string, error) {
|
||||
parentAbsolute, err := filepath.Abs(strings.TrimSpace(parent))
|
||||
if err != nil || strings.TrimSpace(parent) == "" {
|
||||
return "", "", errors.New("invalid purge parent")
|
||||
}
|
||||
childAbsolute, err := filepath.Abs(strings.TrimSpace(child))
|
||||
if err != nil || filepath.Dir(childAbsolute) != parentAbsolute {
|
||||
return "", "", errors.New("purge target escaped its parent")
|
||||
}
|
||||
return parentAbsolute, childAbsolute, nil
|
||||
}
|
||||
|
||||
func validateTree(ctx context.Context, root string) error {
|
||||
return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("purge target contains a symbolic link: %s", path)
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("purge target contains a non-regular artifact: %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func removeTree(ctx context.Context, parent, target string) (bool, error) {
|
||||
parent, target, err := exactChild(parent, target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
parentInfo, err := os.Lstat(parent)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil || parentInfo.Mode()&os.ModeSymlink != 0 || !parentInfo.IsDir() {
|
||||
return false, errors.New("purge parent is not a real directory")
|
||||
}
|
||||
info, err := os.Lstat(target)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return false, errors.New("purge target is not a real directory")
|
||||
}
|
||||
if err := validateTree(ctx, target); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := os.Lstat(target); !os.IsNotExist(err) {
|
||||
if err == nil {
|
||||
return false, errors.New("purge target still exists")
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func evidence(tenantID string, generation int64) string {
|
||||
digest := sha256.Sum256([]byte(
|
||||
"nekonest-cloud/tenant-logical-purge/v1\x00" + tenantID + "\x00" + strconv.FormatInt(generation, 10),
|
||||
))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// Purge performs an idempotent application-layer deletion of one tenant's
|
||||
// live Relay directory and every backup directory. It refuses links and
|
||||
// special files rather than following them. Storage-provider block erasure is
|
||||
// outside this primitive and must be covered by the infrastructure policy.
|
||||
func Purge(ctx context.Context, dataRoot, backupRoot, tenantID string, generation int64) (Result, error) {
|
||||
if generation < 1 {
|
||||
return Result{}, errors.New("invalid purge generation")
|
||||
}
|
||||
paths, err := tenantfs.Derive(dataRoot, tenantID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
dataParent := filepath.Dir(paths.Root)
|
||||
backupRootAbsolute, err := filepath.Abs(strings.TrimSpace(backupRoot))
|
||||
if err != nil || strings.TrimSpace(backupRoot) == "" {
|
||||
return Result{}, errors.New("invalid backup root")
|
||||
}
|
||||
backupTenantRoot := filepath.Join(backupRootAbsolute, filepath.Base(paths.Root))
|
||||
dataDeleted, err := removeTree(ctx, dataParent, paths.Root)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("delete tenant Relay data: %w", err)
|
||||
}
|
||||
backupsDeleted, err := removeTree(ctx, backupRootAbsolute, backupTenantRoot)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("delete tenant Relay backups: %w", err)
|
||||
}
|
||||
return Result{
|
||||
DataDeleted: dataDeleted, BackupsDeleted: backupsDeleted,
|
||||
EvidenceSHA256: evidence(tenantID, generation),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tenantpurge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
||||
)
|
||||
|
||||
const testTenantID = "tenant_0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestPurgeDeletesLiveDataAndEveryBackupIdempotently(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
backupRoot := t.TempDir()
|
||||
paths, err := tenantfs.Resolve(dataRoot, testTenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Database, []byte("sqlite"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backupTenant := filepath.Join(backupRoot, filepath.Base(paths.Root))
|
||||
for _, name := range []string{"g1", "g2"} {
|
||||
path := filepath.Join(backupTenant, name)
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "manifest.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
first, err := Purge(context.Background(), dataRoot, backupRoot, testTenantID, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.DataDeleted || !first.BackupsDeleted || len(first.EvidenceSHA256) != 64 {
|
||||
t.Fatalf("purge result = %#v", first)
|
||||
}
|
||||
if _, err := os.Lstat(paths.Root); !os.IsNotExist(err) {
|
||||
t.Fatalf("live tenant data survived: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(backupTenant); !os.IsNotExist(err) {
|
||||
t.Fatalf("tenant backups survived: %v", err)
|
||||
}
|
||||
second, err := Purge(context.Background(), dataRoot, backupRoot, testTenantID, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.DataDeleted || second.BackupsDeleted || second.EvidenceSHA256 != first.EvidenceSHA256 {
|
||||
t.Fatalf("idempotent retry = %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeRefusesSymbolicLinksWithoutTouchingTarget(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
backupRoot := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outsideFile := filepath.Join(outside, "keep.txt")
|
||||
if err := os.WriteFile(outsideFile, []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, err := tenantfs.Resolve(dataRoot, testTenantID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideFile, filepath.Join(paths.Root, "escape")); err != nil {
|
||||
t.Skipf("symbolic links unavailable: %v", err)
|
||||
}
|
||||
if _, err := Purge(context.Background(), dataRoot, backupRoot, testTenantID, 1); err == nil {
|
||||
t.Fatal("purge followed or accepted a symbolic link")
|
||||
}
|
||||
if data, err := os.ReadFile(outsideFile); err != nil || string(data) != "keep" {
|
||||
t.Fatalf("outside target changed: data=%q err=%v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
corestore "github.com/klarkxy/nekonest/relaycore/store"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DB wraps the SQLite database connection.
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
preexistingApplication bool
|
||||
}
|
||||
|
||||
// New creates and initializes a new database.
|
||||
func New(dbPath string) (*DB, error) {
|
||||
return NewWithTransportMode(dbPath, "")
|
||||
}
|
||||
|
||||
// NewWithTransportMode creates and initializes a database, then establishes
|
||||
// its one persistent transport mode. requestedMode is only meaningful for the
|
||||
// first initialization; a later mismatch is rejected rather than silently
|
||||
// changing how a nest carries application data.
|
||||
func NewWithTransportMode(dbPath, requestedMode string) (*DB, error) {
|
||||
if err := preparePrivateDatabase(dbPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// modernc.org/sqlite applies connection-local PRAGMAs through repeated
|
||||
// _pragma query parameters. The similarly named _journal_mode and
|
||||
// _busy_timeout parameters are not recognized by this driver, which leaves
|
||||
// concurrent WebSocket handlers vulnerable to immediate SQLITE_BUSY errors.
|
||||
conn, err := sql.Open(
|
||||
"sqlite",
|
||||
dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A Relay process hosts many tenants. Bound each SQLite pool and database
|
||||
// independently so one Nest cannot exhaust all descriptors or disk.
|
||||
conn.SetMaxOpenConns(4)
|
||||
conn.SetMaxIdleConns(2)
|
||||
conn.SetConnMaxIdleTime(5 * time.Minute)
|
||||
if _, err := conn.Exec(`PRAGMA max_page_count = 262144`); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("set tenant sqlite page limit: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{conn: conn}
|
||||
hadApplicationTables, err := db.hasApplicationTables()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
db.preexistingApplication = hadApplicationTables
|
||||
if _, err := db.bootstrapTransportMode(requestedMode); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := db.migrate(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := tightenPrivateDatabaseArtifacts(dbPath); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
_, err := db.conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
os TEXT NOT NULL DEFAULT 'windows',
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
active_agents INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pair_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id)
|
||||
);
|
||||
|
||||
-- P2-A: Session message history
|
||||
CREATE TABLE IF NOT EXISTS session_messages (
|
||||
id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'assistant',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'text',
|
||||
timestamp INTEGER NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (id, device_id, session_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_device_session
|
||||
ON session_messages(device_id, session_id, timestamp);
|
||||
|
||||
-- P2-C: Push notification subscriptions
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL DEFAULT '',
|
||||
auth TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(endpoint, device_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_device ON push_subscriptions(device_id);
|
||||
|
||||
-- Durable phone -> daemon command state. A client-generated id is scoped
|
||||
-- to one device and is never forwarded twice.
|
||||
CREATE TABLE IF NOT EXISTS prompt_commands (
|
||||
device_id TEXT NOT NULL,
|
||||
client_msg_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
sealed_envelope_json TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'registered'
|
||||
CHECK(status IN ('registered', 'pending', 'accepted', 'failed', 'indeterminate')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
retry_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
commit_sent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, client_msg_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prompt_commands_status
|
||||
ON prompt_commands(status, updated_at);
|
||||
|
||||
-- Schema version tracking (v1+)
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Independent phone identities (v1)
|
||||
CREATE TABLE IF NOT EXISTS phone_identities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
ed25519_public TEXT NOT NULL DEFAULT '',
|
||||
x25519_public TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
revoked_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_phone_token ON phone_identities(token_hash);
|
||||
|
||||
-- Phone → host device grants (pairing result)
|
||||
CREATE TABLE IF NOT EXISTS phone_device_grants (
|
||||
phone_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (phone_id, device_id),
|
||||
FOREIGN KEY (phone_id) REFERENCES phone_identities(id),
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_device ON phone_device_grants(device_id);
|
||||
|
||||
-- E2E wrapped key packages (ciphertext only on server)
|
||||
CREATE TABLE IF NOT EXISTS key_packages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phone_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
epoch INTEGER NOT NULL,
|
||||
wrapped_key TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(phone_id, device_id, scope, session_id, epoch)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_packages_phone ON key_packages(phone_id, device_id);
|
||||
|
||||
-- Sealed-safe attention routing events. The server intentionally stores
|
||||
-- no prompt, answer, path, approval detail, or event class here.
|
||||
CREATE TABLE IF NOT EXISTS attention_events (
|
||||
device_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attention_events_created_at
|
||||
ON attention_events(created_at);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.migratePushSubscriptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.migratePromptCommands(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.migratePushPhoneID(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.migrateDeviceIdentityColumns(); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ensureSchemaVersion()
|
||||
}
|
||||
|
||||
// hasApplicationTables reports whether this database already contained
|
||||
// NekoNest data before the current migration created its tables. schema_meta is
|
||||
// deliberately excluded so a brand-new database remains distinguishable.
|
||||
func (db *DB) hasApplicationTables() (bool, error) {
|
||||
const q = `SELECT 1 FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (
|
||||
'devices', 'pair_codes', 'user_tokens', 'session_messages',
|
||||
'push_subscriptions', 'prompt_commands', 'phone_identities',
|
||||
'phone_device_grants', 'key_packages', 'attention_events'
|
||||
) LIMIT 1`
|
||||
var one int
|
||||
err := db.conn.QueryRow(q).Scan(&one)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// InitializeTransportMode returns the immutable mode for this nest. Existing
|
||||
// mode metadata is authoritative. A legacy application database with no mode
|
||||
// metadata is explicitly classified as open once; a genuinely new nest starts
|
||||
// sealed unless an explicit first-run mode was supplied.
|
||||
func (db *DB) InitializeTransportMode(requestedMode string) (protocol.TransportMode, error) {
|
||||
return initializeTransportMode(db.conn, db.preexistingApplication, requestedMode)
|
||||
}
|
||||
|
||||
type transportModeStore interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// bootstrapTransportMode creates and pins the immutable nest mode in one
|
||||
// transaction before application tables are migrated. If startup is
|
||||
// interrupted after this point, a new sealed database can never be mistaken
|
||||
// for a legacy open database merely because some tables already exist.
|
||||
func (db *DB) bootstrapTransportMode(requestedMode string) (protocol.TransportMode, error) {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode, err := initializeTransportMode(tx, db.preexistingApplication, requestedMode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func initializeTransportMode(store transportModeStore, preexistingApplication bool, requestedMode string) (protocol.TransportMode, error) {
|
||||
requestedMode = strings.TrimSpace(requestedMode)
|
||||
var requested protocol.TransportMode
|
||||
if requestedMode != "" {
|
||||
parsed, err := protocol.ParseTransportMode(requestedMode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid requested transport_mode: %w", err)
|
||||
}
|
||||
requested = parsed
|
||||
}
|
||||
|
||||
var stored string
|
||||
err := store.QueryRow(`SELECT value FROM schema_meta WHERE key = 'transport_mode'`).Scan(&stored)
|
||||
if err == nil {
|
||||
mode, parseErr := protocol.ParseTransportMode(stored)
|
||||
if parseErr != nil {
|
||||
return "", fmt.Errorf("stored transport_mode is invalid: %w", parseErr)
|
||||
}
|
||||
if requested != "" && requested != mode {
|
||||
return "", fmt.Errorf("transport_mode mismatch: persisted %s, requested %s", mode, requested)
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := protocol.TransportSealed
|
||||
if preexistingApplication {
|
||||
mode = protocol.TransportOpen
|
||||
}
|
||||
if requested != "" {
|
||||
if preexistingApplication && requested != protocol.TransportOpen {
|
||||
return "", fmt.Errorf("transport_mode mismatch: legacy nest is open; use the offline migration before sealed")
|
||||
}
|
||||
mode = requested
|
||||
}
|
||||
if _, err := store.Exec(`INSERT INTO schema_meta (key, value) VALUES ('transport_mode', ?)`, string(mode)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
// TransportMode reads the persistent mode. Callers must treat an error as a
|
||||
// fail-closed startup condition rather than choosing a fallback relay mode.
|
||||
func (db *DB) TransportMode() (protocol.TransportMode, error) {
|
||||
var raw string
|
||||
if err := db.conn.QueryRow(`SELECT value FROM schema_meta WHERE key = 'transport_mode'`).Scan(&raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode, err := protocol.ParseTransportMode(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stored transport_mode is invalid: %w", err)
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
const attentionEventTTL = 24 * time.Hour
|
||||
|
||||
// AcceptAttentionEvent durably deduplicates an event across server instances.
|
||||
// Only the routing identifiers and timestamp are persisted. Old event ids are
|
||||
// removed opportunistically to bound the table.
|
||||
func (db *DB) AcceptAttentionEvent(deviceID, eventID string, createdAt time.Time) (bool, error) {
|
||||
if strings.TrimSpace(deviceID) == "" || strings.TrimSpace(eventID) == "" {
|
||||
return false, fmt.Errorf("device_id and event_id required")
|
||||
}
|
||||
now := createdAt.Unix()
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM attention_events WHERE created_at < ?`, now-int64(attentionEventTTL/time.Second)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := tx.Exec(
|
||||
`INSERT INTO attention_events (device_id, event_id, created_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id, event_id) DO NOTHING`,
|
||||
deviceID, eventID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return err == nil && n == 1, err
|
||||
}
|
||||
|
||||
// SchemaVersion is the current server schema generation.
|
||||
const SchemaVersion = "1"
|
||||
|
||||
func (db *DB) ensureSchemaVersion() error {
|
||||
var v string
|
||||
err := db.conn.QueryRow(`SELECT value FROM schema_meta WHERE key = 'version'`).Scan(&v)
|
||||
if err == nil && v != "" {
|
||||
return nil
|
||||
}
|
||||
_, err = db.conn.Exec(
|
||||
`INSERT INTO schema_meta (key, value) VALUES ('version', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
SchemaVersion,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SchemaVersion returns the stored schema version string.
|
||||
func (db *DB) GetSchemaVersion() string {
|
||||
var v string
|
||||
if err := db.conn.QueryRow(`SELECT value FROM schema_meta WHERE key = 'version'`).Scan(&v); err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// migratePushPhoneID adds optional phone_id to push_subscriptions for v1 scoping.
|
||||
func (db *DB) migratePushPhoneID() error {
|
||||
var schema string
|
||||
if err := db.conn.QueryRow(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'push_subscriptions'`,
|
||||
).Scan(&schema); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(strings.ToLower(schema), "phone_id") {
|
||||
return nil
|
||||
}
|
||||
_, err := db.conn.Exec(`ALTER TABLE push_subscriptions ADD COLUMN phone_id TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate push phone_id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateDeviceIdentityColumns adds E2E public key fields on devices.
|
||||
func (db *DB) migrateDeviceIdentityColumns() error {
|
||||
cols := []struct {
|
||||
name string
|
||||
ddl string
|
||||
}{
|
||||
{"ed25519_public", `ALTER TABLE devices ADD COLUMN ed25519_public TEXT NOT NULL DEFAULT ''`},
|
||||
{"x25519_public", `ALTER TABLE devices ADD COLUMN x25519_public TEXT NOT NULL DEFAULT ''`},
|
||||
{"identity_fingerprint", `ALTER TABLE devices ADD COLUMN identity_fingerprint TEXT NOT NULL DEFAULT ''`},
|
||||
{"revoked_at", `ALTER TABLE devices ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`},
|
||||
}
|
||||
for _, c := range cols {
|
||||
var schema string
|
||||
if err := db.conn.QueryRow(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'devices'`,
|
||||
).Scan(&schema); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(strings.ToLower(schema), strings.ToLower(c.name)) {
|
||||
continue
|
||||
}
|
||||
if _, err := db.conn.Exec(c.ddl); err != nil {
|
||||
return fmt.Errorf("migrate devices.%s: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDevicePublicKeys stores daemon E2E public keys (base64url) and fingerprint.
|
||||
func (db *DB) SetDevicePublicKeys(deviceID, ed25519Pub, x25519Pub, fingerprint string) error {
|
||||
res, err := db.conn.Exec(
|
||||
`UPDATE devices SET ed25519_public = ?, x25519_public = ?, identity_fingerprint = ? WHERE id = ?`,
|
||||
ed25519Pub, x25519Pub, fingerprint, deviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("device not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DevicePublicKeys is the public E2E material for a host daemon.
|
||||
type DevicePublicKeys = corestore.DevicePublicKeys
|
||||
|
||||
// GetDevicePublicKeys returns stored daemon public keys (may be empty).
|
||||
func (db *DB) GetDevicePublicKeys(deviceID string) (*DevicePublicKeys, error) {
|
||||
row := db.conn.QueryRow(
|
||||
`SELECT ed25519_public, x25519_public, identity_fingerprint FROM devices WHERE id = ?`,
|
||||
deviceID,
|
||||
)
|
||||
var k DevicePublicKeys
|
||||
if err := row.Scan(&k.Ed25519Public, &k.X25519Public, &k.Fingerprint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// ClearPlaintextContentForV1 wipes server-held plaintext application content
|
||||
// after a verified backup. Preserves devices (ids + token hashes) and schema.
|
||||
// Phones must re-login/re-pair; native agent stores on hosts are untouched.
|
||||
func (db *DB) ClearPlaintextContentForV1() error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, q := range []string{
|
||||
`DELETE FROM session_messages`,
|
||||
`DELETE FROM prompt_commands`,
|
||||
`DELETE FROM pair_codes`,
|
||||
`DELETE FROM push_subscriptions`,
|
||||
`DELETE FROM key_packages`,
|
||||
`DELETE FROM phone_device_grants`,
|
||||
`DELETE FROM phone_identities`,
|
||||
`DELETE FROM user_tokens`,
|
||||
} {
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
// Table may not exist on very old DBs — ignore.
|
||||
if !strings.Contains(err.Error(), "no such table") {
|
||||
return fmt.Errorf("%s: %w", q, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_meta (key, value) VALUES ('version', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
SchemaVersion,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_meta (key, value) VALUES ('migrated_v1_at', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
fmt.Sprintf("%d", time.Now().Unix()),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// This routine is reachable only from the offline migrator after a verified
|
||||
// backup and plaintext cleanup. Make the sealed cutover part of the same
|
||||
// database transaction; normal startup can never switch an existing nest.
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_meta (key, value) VALUES ('transport_mode', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
string(protocol.TransportSealed),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// migratePushSubscriptions upgrades the original UNIQUE(endpoint) schema to a
|
||||
// per-device mapping. Browsers intentionally reuse one PushSubscription for
|
||||
// every device selected in the same PWA.
|
||||
func (db *DB) migratePushSubscriptions() error {
|
||||
var schema string
|
||||
if err := db.conn.QueryRow(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'push_subscriptions'`,
|
||||
).Scan(&schema); err != nil {
|
||||
return err
|
||||
}
|
||||
compact := strings.Join(strings.Fields(strings.ToLower(schema)), "")
|
||||
if strings.Contains(compact, "unique(endpoint,device_id)") {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE push_subscriptions_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL DEFAULT '',
|
||||
auth TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(endpoint, device_id)
|
||||
);
|
||||
INSERT OR REPLACE INTO push_subscriptions_v2
|
||||
(id, device_id, endpoint, p256dh, auth, created_at)
|
||||
SELECT id, device_id, endpoint, p256dh, auth, created_at
|
||||
FROM push_subscriptions;
|
||||
DROP TABLE push_subscriptions;
|
||||
ALTER TABLE push_subscriptions_v2 RENAME TO push_subscriptions;
|
||||
CREATE INDEX idx_push_device ON push_subscriptions(device_id);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migrate push subscriptions: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// migratePromptCommands adds the non-retryable indeterminate terminal state
|
||||
// used when the daemon cannot prove whether an external CLI accepted a prompt.
|
||||
func (db *DB) migratePromptCommands() error {
|
||||
var schema string
|
||||
if err := db.conn.QueryRow(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'prompt_commands'`,
|
||||
).Scan(&schema); err != nil {
|
||||
return err
|
||||
}
|
||||
compact := strings.Join(strings.Fields(strings.ToLower(schema)), "")
|
||||
if strings.Contains(compact, "'registered'") &&
|
||||
strings.Contains(compact, "'indeterminate'") &&
|
||||
strings.Contains(compact, "retry_allowed") &&
|
||||
strings.Contains(compact, "outcome") &&
|
||||
strings.Contains(compact, "commit_sent") &&
|
||||
strings.Contains(compact, "sealed_envelope_json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE prompt_commands_v2 (
|
||||
device_id TEXT NOT NULL,
|
||||
client_msg_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
sealed_envelope_json TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'registered'
|
||||
CHECK(status IN ('registered', 'pending', 'accepted', 'failed', 'indeterminate')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
retry_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
commit_sent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, client_msg_id)
|
||||
);
|
||||
INSERT INTO prompt_commands_v2
|
||||
(device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
|
||||
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at)
|
||||
SELECT device_id, client_msg_id, session_id, prompt, attachments_json, '',
|
||||
status, error,
|
||||
CASE status
|
||||
WHEN 'accepted' THEN 'accepted'
|
||||
WHEN 'failed' THEN 'failed'
|
||||
ELSE ''
|
||||
END,
|
||||
CASE status WHEN 'failed' THEN 1 ELSE 0 END,
|
||||
0,
|
||||
created_at, updated_at
|
||||
FROM prompt_commands;
|
||||
DROP TABLE prompt_commands;
|
||||
ALTER TABLE prompt_commands_v2 RENAME TO prompt_commands;
|
||||
CREATE INDEX idx_prompt_commands_status
|
||||
ON prompt_commands(status, updated_at);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migrate prompt commands: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RegisterDevice registers a new device and returns its token.
|
||||
// osName should be "windows" or "linux" (v1 formal hosts); empty defaults to windows.
|
||||
func (db *DB) RegisterDevice(id, name string, osName ...string) (string, error) {
|
||||
token := generateToken()
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("secure device credential generation failed")
|
||||
}
|
||||
tokenHash := hashToken(token)
|
||||
now := time.Now().Unix()
|
||||
osVal := "windows"
|
||||
if len(osName) > 0 {
|
||||
switch strings.ToLower(strings.TrimSpace(osName[0])) {
|
||||
case "linux":
|
||||
osVal = "linux"
|
||||
case "windows", "":
|
||||
osVal = "windows"
|
||||
default:
|
||||
// Keep unknown values for forward compatibility (e.g. future darwin).
|
||||
if s := strings.TrimSpace(osName[0]); s != "" {
|
||||
osVal = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO devices (id, name, os, token_hash, created_at, last_seen) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
id, name, osVal, tokenHash, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetDevice retrieves a device by ID.
|
||||
func (db *DB) GetDevice(id string) (*protocol.Device, error) {
|
||||
row := db.conn.QueryRow(`SELECT id, name, os, last_seen FROM devices WHERE id = ? AND revoked_at = 0`, id)
|
||||
var d protocol.Device
|
||||
if err := row.Scan(&d.ID, &d.Name, &d.OS, &d.LastSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Status = "offline" // default, updated by connection manager
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// DeviceExists reports whether a subscription target is registered.
|
||||
func (db *DB) DeviceExists(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
var exists int
|
||||
err := db.conn.QueryRow(`SELECT 1 FROM devices WHERE id = ? AND revoked_at = 0 LIMIT 1`, id).Scan(&exists)
|
||||
return err == nil && exists == 1
|
||||
}
|
||||
|
||||
// UpdateDeviceLastSeen updates the last seen timestamp.
|
||||
func (db *DB) UpdateDeviceLastSeen(id string) error {
|
||||
_, err := db.conn.Exec(`UPDATE devices SET last_seen = ? WHERE id = ? AND revoked_at = 0`, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDevices returns all registered devices.
|
||||
func (db *DB) ListDevices() ([]*protocol.Device, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name, os, last_seen, active_agents FROM devices WHERE revoked_at = 0 ORDER BY last_seen DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []*protocol.Device
|
||||
for rows.Next() {
|
||||
d := &protocol.Device{}
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.OS, &d.LastSeen, &d.ActiveAgents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Status = "offline"
|
||||
devices = append(devices, d)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// ValidateDeviceToken checks if a device token is valid.
|
||||
func (db *DB) ValidateDeviceToken(deviceID, token string) bool {
|
||||
tokenHash := hashToken(token)
|
||||
var count int
|
||||
err := db.conn.QueryRow(`SELECT COUNT(*) FROM devices WHERE id = ? AND token_hash = ? AND revoked_at = 0`, deviceID, tokenHash).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// CreatePairCode generates a temporary pairing code.
|
||||
func (db *DB) CreatePairCode(code, deviceID string, expiresAt time.Time) error {
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO pair_codes (code, device_id, expires_at) VALUES (?, ?, ?)`,
|
||||
code, deviceID, expiresAt.Unix(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumePairCode validates and marks a pair code as used (atomic single-winner).
|
||||
func (db *DB) ConsumePairCode(code string) (string, error) {
|
||||
now := time.Now().Unix()
|
||||
res, err := db.conn.Exec(
|
||||
`UPDATE pair_codes SET used = 1 WHERE code = ? AND used = 0 AND expires_at >= ?`,
|
||||
code, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n == 0 {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
var deviceID string
|
||||
err = db.conn.QueryRow(`SELECT device_id FROM pair_codes WHERE code = ?`, code).Scan(&deviceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return deviceID, nil
|
||||
}
|
||||
|
||||
// UpdateDeviceSessions updates the session-count hint stored in active_agents.
|
||||
func (db *DB) UpdateDeviceSessions(id string, count int) error {
|
||||
_, err := db.conn.Exec(`UPDATE devices SET active_agents = ?, last_seen = ? WHERE id = ? AND revoked_at = 0`, count, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
// SaveSealedMessage persists an opaque sealed session_message envelope.
|
||||
// No application plaintext is written; ciphertext lives in metadata_json.
|
||||
func (db *DB) SaveSealedMessage(deviceID, sessionID string, msg *protocol.NekoMessage) error {
|
||||
if msg == nil || msg.SealedPayload == nil {
|
||||
return nil
|
||||
}
|
||||
id := msg.ClientMsgID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("sealed_%d_%d", msg.Timestamp, msg.SealedPayload.Sequence)
|
||||
}
|
||||
meta, _ := marshalJSON(map[string]any{
|
||||
"sealed": true,
|
||||
"sealed_payload": msg.SealedPayload,
|
||||
"protocol_version": msg.ProtocolVersion,
|
||||
"transport_mode": msg.TransportMode,
|
||||
})
|
||||
_, err := db.conn.Exec(`
|
||||
INSERT INTO session_messages (id, device_id, session_id, role, content, type, timestamp, metadata_json)
|
||||
VALUES (?, ?, ?, 'assistant', '', 'sealed', ?, ?)
|
||||
ON CONFLICT(id, device_id, session_id) DO UPDATE SET
|
||||
timestamp = excluded.timestamp,
|
||||
metadata_json = excluded.metadata_json`,
|
||||
id, deviceID, sessionID, msg.Timestamp, string(meta),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveMessage stores a session message in the database.
|
||||
// Same id is upserted so streaming patches update content in place.
|
||||
func (db *DB) SaveMessage(deviceID, sessionID string, msg *protocol.SessionMessage) error {
|
||||
_, err := db.conn.Exec(`
|
||||
INSERT INTO session_messages (id, device_id, session_id, role, content, type, timestamp, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id, device_id, session_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
type = excluded.type,
|
||||
timestamp = excluded.timestamp,
|
||||
metadata_json = excluded.metadata_json,
|
||||
role = excluded.role`,
|
||||
msg.ID, deviceID, sessionID, msg.Role, msg.Content, msg.Type, msg.Timestamp,
|
||||
metadataToJSON(msg.Metadata),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetMessages retrieves messages for a session, ordered by timestamp.
|
||||
// limit=0 means no limit.
|
||||
func (db *DB) GetMessages(deviceID, sessionID string, limit int) ([]*protocol.SessionMessage, error) {
|
||||
query := `SELECT id, role, content, type, timestamp, metadata_json
|
||||
FROM session_messages
|
||||
WHERE device_id = ? AND session_id = ?
|
||||
ORDER BY timestamp ASC`
|
||||
|
||||
if limit > 0 {
|
||||
query = `SELECT id, role, content, type, timestamp, metadata_json
|
||||
FROM session_messages
|
||||
WHERE device_id = ? AND session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if limit > 0 {
|
||||
rows, err = db.conn.Query(query, deviceID, sessionID, limit)
|
||||
} else {
|
||||
rows, err = db.conn.Query(query, deviceID, sessionID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []*protocol.SessionMessage
|
||||
for rows.Next() {
|
||||
msg := &protocol.SessionMessage{}
|
||||
var metadataJSON sql.NullString
|
||||
if err := rows.Scan(&msg.ID, &msg.Role, &msg.Content, &msg.Type, &msg.Timestamp, &metadataJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metadataJSON.Valid {
|
||||
msg.Metadata = jsonToMetadata(metadataJSON.String)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If we used LIMIT, reverse to get chronological order
|
||||
if limit > 0 {
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetMessageCount returns the number of messages for a session.
|
||||
func (db *DB) GetMessageCount(deviceID, sessionID string) (int, error) {
|
||||
var count int
|
||||
err := db.conn.QueryRow(
|
||||
`SELECT COUNT(*) FROM session_messages WHERE device_id = ? AND session_id = ?`,
|
||||
deviceID, sessionID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DeleteOldMessages removes messages older than the given timestamp.
|
||||
func (db *DB) DeleteOldMessages(before time.Time) (int64, error) {
|
||||
result, err := db.conn.Exec(
|
||||
`DELETE FROM session_messages WHERE timestamp < ?`,
|
||||
before.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// DeleteSessionMessages removes all messages for a session.
|
||||
func (db *DB) DeleteSessionMessages(deviceID, sessionID string) error {
|
||||
_, err := db.conn.Exec(
|
||||
`DELETE FROM session_messages WHERE device_id = ? AND session_id = ?`,
|
||||
deviceID, sessionID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSessionsWithMessages returns session IDs that have stored messages for a device.
|
||||
func (db *DB) ListSessionsWithMessages(deviceID string) ([]string, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT session_id FROM session_messages WHERE device_id = ? GROUP BY session_id ORDER BY MAX(timestamp) DESC`,
|
||||
deviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessionIDs []string
|
||||
for rows.Next() {
|
||||
var sid string
|
||||
if err := rows.Scan(&sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionIDs = append(sessionIDs, sid)
|
||||
}
|
||||
return sessionIDs, nil
|
||||
}
|
||||
|
||||
// metadataToJSON converts metadata map to a JSON string for storage.
|
||||
func metadataToJSON(m map[string]any) string {
|
||||
if m == nil {
|
||||
return "{}"
|
||||
}
|
||||
// Simple JSON serialization for metadata
|
||||
data, err := marshalJSON(m)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// jsonToMetadata parses a JSON string back to metadata map.
|
||||
func jsonToMetadata(s string) map[string]any {
|
||||
if s == "" || s == "{}" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := unmarshalJSON([]byte(s), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const privateDatabaseMode os.FileMode = 0o600
|
||||
|
||||
func preparePrivateDatabase(dbPath string) error {
|
||||
if info, err := os.Lstat(dbPath); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("sqlite database must be a regular file")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect sqlite database: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(dbPath, os.O_CREATE|os.O_RDWR, privateDatabaseMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open private sqlite database: %w", err)
|
||||
}
|
||||
if err := file.Chmod(privateDatabaseMode); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("secure sqlite database: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close private sqlite database: %w", err)
|
||||
}
|
||||
return tightenPrivateDatabaseArtifacts(dbPath)
|
||||
}
|
||||
|
||||
func tightenPrivateDatabaseArtifacts(dbPath string) error {
|
||||
for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
|
||||
if info, err := os.Lstat(path); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("sqlite artifact must be a regular file")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect sqlite artifact: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, privateDatabaseMode); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("secure sqlite artifact: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corestore "github.com/klarkxy/nekonest/relaycore/store"
|
||||
)
|
||||
|
||||
// PhoneIdentity is an independent phone client identity.
|
||||
type PhoneIdentity = corestore.PhoneIdentity
|
||||
|
||||
// PhoneAuth is the result of validating a phone bearer token.
|
||||
type PhoneAuth = corestore.PhoneAuth
|
||||
|
||||
var (
|
||||
ErrPhoneNotFound = corestore.ErrPhoneNotFound
|
||||
ErrPhoneRevoked = corestore.ErrPhoneRevoked
|
||||
ErrPhoneTokenInvalid = corestore.ErrPhoneTokenInvalid
|
||||
ErrGrantNotFound = errors.New("device grant not found")
|
||||
ErrGrantRevoked = errors.New("device grant revoked")
|
||||
)
|
||||
|
||||
// CreatePhoneIdentity mints a new phone identity and returns the plaintext token once.
|
||||
func (db *DB) CreatePhoneIdentity(name string) (phoneID, token string, err error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Phone"
|
||||
}
|
||||
token = generateToken()
|
||||
phoneRandom := generateToken()
|
||||
if token == "" || phoneRandom == "" {
|
||||
return "", "", errors.New("secure phone credential generation failed")
|
||||
}
|
||||
phoneID = "phone_" + phoneRandom[:16]
|
||||
now := time.Now().Unix()
|
||||
_, err = db.conn.Exec(
|
||||
`INSERT INTO phone_identities (id, name, token_hash, ed25519_public, x25519_public, created_at, last_seen, revoked_at)
|
||||
VALUES (?, ?, ?, '', '', ?, ?, 0)`,
|
||||
phoneID, name, hashToken(token), now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return phoneID, token, nil
|
||||
}
|
||||
|
||||
// ValidatePhoneToken returns phone auth for an active token.
|
||||
func (db *DB) ValidatePhoneToken(token string) (*PhoneAuth, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, ErrPhoneTokenInvalid
|
||||
}
|
||||
row := db.conn.QueryRow(
|
||||
`SELECT id, name, token_hash, revoked_at FROM phone_identities WHERE token_hash = ?`,
|
||||
hashToken(token),
|
||||
)
|
||||
var id, name, tokenHash string
|
||||
var revokedAt int64
|
||||
if err := row.Scan(&id, &name, &tokenHash, &revokedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrPhoneTokenInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Constant-time compare of hashes (already looked up by hash; still guards timing).
|
||||
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hashToken(token))) != 1 {
|
||||
return nil, ErrPhoneTokenInvalid
|
||||
}
|
||||
if revokedAt > 0 {
|
||||
return nil, ErrPhoneRevoked
|
||||
}
|
||||
_, _ = db.conn.Exec(`UPDATE phone_identities SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
return &PhoneAuth{PhoneID: id, Name: name}, nil
|
||||
}
|
||||
|
||||
// GetPhone returns a phone identity by id.
|
||||
func (db *DB) GetPhone(id string) (*PhoneIdentity, error) {
|
||||
row := db.conn.QueryRow(
|
||||
`SELECT id, name, ed25519_public, x25519_public, created_at, last_seen, revoked_at
|
||||
FROM phone_identities WHERE id = ?`, id,
|
||||
)
|
||||
var p PhoneIdentity
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Ed25519Public, &p.X25519Public, &p.CreatedAt, &p.LastSeen, &p.RevokedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrPhoneNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListPhones returns all phone identities (including revoked).
|
||||
func (db *DB) ListPhones() ([]*PhoneIdentity, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, name, ed25519_public, x25519_public, created_at, last_seen, revoked_at
|
||||
FROM phone_identities ORDER BY created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*PhoneIdentity
|
||||
for rows.Next() {
|
||||
var p PhoneIdentity
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Ed25519Public, &p.X25519Public, &p.CreatedAt, &p.LastSeen, &p.RevokedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetPhonePublicKeys stores E2E public keys for a phone.
|
||||
func (db *DB) SetPhonePublicKeys(phoneID, ed25519Pub, x25519Pub string) error {
|
||||
res, err := db.conn.Exec(
|
||||
`UPDATE phone_identities SET ed25519_public = ?, x25519_public = ? WHERE id = ? AND revoked_at = 0`,
|
||||
ed25519Pub, x25519Pub, phoneID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrPhoneNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokePhone marks a phone revoked and revokes all its device grants.
|
||||
func (db *DB) RevokePhone(phoneID string) error {
|
||||
now := time.Now().Unix()
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE phone_identities SET revoked_at = ? WHERE id = ? AND revoked_at = 0`,
|
||||
now, phoneID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
// Already revoked or missing — check existence
|
||||
var exists int
|
||||
if err := tx.QueryRow(`SELECT 1 FROM phone_identities WHERE id = ?`, phoneID).Scan(&exists); err != nil {
|
||||
return ErrPhoneNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE phone_device_grants SET revoked_at = ? WHERE phone_id = ? AND revoked_at = 0`,
|
||||
now, phoneID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM key_packages WHERE phone_id = ?`, phoneID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM push_subscriptions WHERE phone_id = ?`, phoneID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GrantPhoneDevice creates or reactivates a phone→device grant.
|
||||
func (db *DB) GrantPhoneDevice(phoneID, deviceID string) error {
|
||||
if phoneID == "" || deviceID == "" {
|
||||
return fmt.Errorf("phone_id and device_id required")
|
||||
}
|
||||
// Ensure phone is active
|
||||
p, err := db.GetPhone(phoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.RevokedAt > 0 {
|
||||
return ErrPhoneRevoked
|
||||
}
|
||||
if !db.DeviceExists(deviceID) {
|
||||
return fmt.Errorf("unknown device")
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err = db.conn.Exec(`
|
||||
INSERT INTO phone_device_grants (phone_id, device_id, paired_at, revoked_at)
|
||||
VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(phone_id, device_id) DO UPDATE SET
|
||||
paired_at = excluded.paired_at,
|
||||
revoked_at = 0
|
||||
`, phoneID, deviceID, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokePhoneDeviceGrant revokes one phone→device grant.
|
||||
func (db *DB) RevokePhoneDeviceGrant(phoneID, deviceID string) error {
|
||||
now := time.Now().Unix()
|
||||
res, err := db.conn.Exec(
|
||||
`UPDATE phone_device_grants SET revoked_at = ? WHERE phone_id = ? AND device_id = ? AND revoked_at = 0`,
|
||||
now, phoneID, deviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrGrantNotFound
|
||||
}
|
||||
_, _ = db.conn.Exec(
|
||||
`DELETE FROM key_packages WHERE phone_id = ? AND device_id = ?`,
|
||||
phoneID, deviceID,
|
||||
)
|
||||
_, _ = db.conn.Exec(
|
||||
`DELETE FROM push_subscriptions WHERE phone_id = ? AND device_id = ?`,
|
||||
phoneID, deviceID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhoneHasDeviceGrant reports whether phone may access device.
|
||||
func (db *DB) PhoneHasDeviceGrant(phoneID, deviceID string) bool {
|
||||
if phoneID == "" || deviceID == "" {
|
||||
return false
|
||||
}
|
||||
var n int
|
||||
err := db.conn.QueryRow(
|
||||
`SELECT 1 FROM phone_device_grants
|
||||
WHERE phone_id = ? AND device_id = ? AND revoked_at = 0 LIMIT 1`,
|
||||
phoneID, deviceID,
|
||||
).Scan(&n)
|
||||
return err == nil && n == 1
|
||||
}
|
||||
|
||||
// ListPhoneDeviceIDs returns active device grants for a phone.
|
||||
func (db *DB) ListPhoneDeviceIDs(phoneID string) ([]string, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT device_id FROM phone_device_grants
|
||||
WHERE phone_id = ? AND revoked_at = 0 ORDER BY paired_at DESC`,
|
||||
phoneID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertKeyPackage stores a wrapped key package for a phone/device/scope.
|
||||
func (db *DB) UpsertKeyPackage(phoneID, deviceID, scope, sessionID string, epoch uint64, wrappedKey, nonce string) error {
|
||||
now := time.Now().Unix()
|
||||
_, err := db.conn.Exec(`
|
||||
INSERT INTO key_packages (phone_id, device_id, scope, session_id, epoch, wrapped_key, nonce, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(phone_id, device_id, scope, session_id, epoch) DO UPDATE SET
|
||||
wrapped_key = excluded.wrapped_key,
|
||||
nonce = excluded.nonce,
|
||||
created_at = excluded.created_at
|
||||
`, phoneID, deviceID, scope, sessionID, epoch, wrappedKey, nonce, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// PhoneGrant describes an active phone→device grant with optional E2E pubs.
|
||||
type PhoneGrant = corestore.PhoneGrant
|
||||
|
||||
// ListPhoneGrantsForDevice returns active grants for a host (daemon key wrap).
|
||||
func (db *DB) ListPhoneGrantsForDevice(deviceID string) ([]*PhoneGrant, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT g.phone_id, g.device_id, p.ed25519_public, p.x25519_public, g.paired_at
|
||||
FROM phone_device_grants g
|
||||
JOIN phone_identities p ON p.id = g.phone_id
|
||||
WHERE g.device_id = ? AND g.revoked_at = 0 AND p.revoked_at = 0
|
||||
ORDER BY g.paired_at DESC`, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*PhoneGrant
|
||||
for rows.Next() {
|
||||
var g PhoneGrant
|
||||
if err := rows.Scan(&g.PhoneID, &g.DeviceID, &g.Ed25519Public, &g.X25519Public, &g.PairedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListKeyPackages returns key packages for a phone (optionally filtered by device).
|
||||
func (db *DB) ListKeyPackages(phoneID, deviceID string) ([]map[string]any, error) {
|
||||
q := `SELECT phone_id, device_id, scope, session_id, epoch, wrapped_key, nonce, created_at
|
||||
FROM key_packages WHERE phone_id = ?`
|
||||
args := []any{phoneID}
|
||||
if deviceID != "" {
|
||||
q += ` AND device_id = ?`
|
||||
args = append(args, deviceID)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC`
|
||||
rows, err := db.conn.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []map[string]any
|
||||
for rows.Next() {
|
||||
var pid, did, scope, sid, wk, nonce string
|
||||
var epoch uint64
|
||||
var created int64
|
||||
if err := rows.Scan(&pid, &did, &scope, &sid, &epoch, &wk, &nonce, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"phone_id": pid,
|
||||
"device_id": did,
|
||||
"scope": scope,
|
||||
"session_id": sid,
|
||||
"epoch": epoch,
|
||||
"wrapped_key": wk,
|
||||
"nonce": nonce,
|
||||
"created_at": created,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klarkxy/nekonest/relaycore"
|
||||
corestore "github.com/klarkxy/nekonest/relaycore/store"
|
||||
)
|
||||
|
||||
var (
|
||||
_ corestore.Store = (*DB)(nil)
|
||||
_ relaycore.PrincipalSynchronizer = (*DB)(nil)
|
||||
)
|
||||
|
||||
func credentialDigest(credential relaycore.Credential) (string, error) {
|
||||
value := strings.TrimSpace(credential.Value)
|
||||
switch credential.Kind {
|
||||
case relaycore.CredentialRaw:
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("credential is required")
|
||||
}
|
||||
return hashToken(value), nil
|
||||
case relaycore.CredentialSHA256:
|
||||
value = strings.ToLower(value)
|
||||
if len(value) != 64 {
|
||||
return "", fmt.Errorf("invalid sha256 credential digest")
|
||||
}
|
||||
for _, char := range value {
|
||||
if !strings.ContainsRune("0123456789abcdef", char) {
|
||||
return "", fmt.Errorf("invalid sha256 credential digest")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported credential kind %q", credential.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// SyncDevice installs a control-plane-approved identity without ever requiring
|
||||
// the plaintext bearer to leave the client/Relay admission boundary.
|
||||
func (db *DB) SyncDevice(id, name, osName string, credential relaycore.Credential) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("device id is required")
|
||||
}
|
||||
digest, err := credentialDigest(credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Cloud host"
|
||||
}
|
||||
osName = strings.TrimSpace(osName)
|
||||
if osName == "" {
|
||||
osName = "unknown"
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err = db.conn.Exec(
|
||||
`INSERT INTO devices (id, name, os, token_hash, created_at, last_seen, active_agents, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
os = excluded.os,
|
||||
token_hash = excluded.token_hash,
|
||||
revoked_at = 0`,
|
||||
id, name, osName, digest, now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeDevice invalidates the bearer while retaining ciphertext history and
|
||||
// audit-relevant local state until the tenant retention policy purges it.
|
||||
func (db *DB) RevokeDevice(id string) error {
|
||||
result, err := db.conn.Exec(
|
||||
`UPDATE devices SET token_hash = ?, revoked_at = ?, active_agents = 0
|
||||
WHERE id = ? AND revoked_at = 0`,
|
||||
hashToken("revoked:"+generateToken()), time.Now().Unix(), strings.TrimSpace(id),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed == 0 {
|
||||
return fmt.Errorf("device not found or already revoked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncPhone mirrors an approved phone credential digest into this tenant's
|
||||
// isolated store. Device grants remain separate and are never created here.
|
||||
func (db *DB) SyncPhone(id, name string, credential relaycore.Credential) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("phone id is required")
|
||||
}
|
||||
digest, err := credentialDigest(credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Cloud phone"
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err = db.conn.Exec(
|
||||
`INSERT INTO phone_identities
|
||||
(id, name, token_hash, ed25519_public, x25519_public, created_at, last_seen, revoked_at)
|
||||
VALUES (?, ?, ?, '', '', ?, ?, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
token_hash = excluded.token_hash,
|
||||
revoked_at = 0`,
|
||||
id, name, digest, now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
corestore "github.com/klarkxy/nekonest/relaycore/store"
|
||||
)
|
||||
|
||||
const (
|
||||
PromptRegistered = corestore.PromptRegistered
|
||||
PromptPending = corestore.PromptPending
|
||||
PromptAccepted = corestore.PromptAccepted
|
||||
PromptFailed = corestore.PromptFailed
|
||||
PromptIndeterminate = corestore.PromptIndeterminate
|
||||
)
|
||||
|
||||
var ErrPromptCommandConflict = corestore.ErrPromptCommandConflict
|
||||
|
||||
// PromptCommand is the durable idempotency record for one phone prompt.
|
||||
type PromptCommand = corestore.PromptCommand
|
||||
|
||||
// RegisterPromptCommand inserts a new registered command. When retryFailed is
|
||||
// explicitly true, a terminal failed command with the exact same immutable
|
||||
// payload is atomically claimed for one retry. The bool reports whether the
|
||||
// caller owns the forward.
|
||||
func (db *DB) RegisterPromptCommand(cmd *PromptCommand, retryFailed bool) (*PromptCommand, bool, error) {
|
||||
if cmd == nil || cmd.DeviceID == "" || cmd.ClientMsgID == "" || cmd.SessionID == "" {
|
||||
return nil, false, errors.New("device_id, client_msg_id and session_id are required")
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
result, err := db.conn.Exec(`
|
||||
INSERT OR IGNORE INTO prompt_commands
|
||||
(device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
|
||||
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'registered', '', '', 0, 0, ?, ?)`,
|
||||
cmd.DeviceID, cmd.ClientMsgID, cmd.SessionID, cmd.Prompt, cmd.AttachmentsJSON, cmd.SealedEnvelopeJSON, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
stored, err := db.GetPromptCommand(cmd.DeviceID, cmd.ClientMsgID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if affected == 0 &&
|
||||
(stored.SessionID != cmd.SessionID ||
|
||||
stored.Prompt != cmd.Prompt ||
|
||||
stored.AttachmentsJSON != cmd.AttachmentsJSON ||
|
||||
stored.SealedEnvelopeJSON != cmd.SealedEnvelopeJSON) {
|
||||
return stored, false, ErrPromptCommandConflict
|
||||
}
|
||||
if affected == 1 {
|
||||
return stored, true, nil
|
||||
}
|
||||
// registered means SQLite durably recorded the command but the server did
|
||||
// not durably observe a successful websocket write. Re-sending the same ID
|
||||
// is safe because the daemon journals IDs before external execution.
|
||||
if stored.Status == PromptRegistered {
|
||||
return stored, true, nil
|
||||
}
|
||||
if stored.Status != PromptFailed || !stored.RetryAllowed || !retryFailed {
|
||||
return stored, false, nil
|
||||
}
|
||||
|
||||
retry, err := db.conn.Exec(`
|
||||
UPDATE prompt_commands
|
||||
SET status = 'registered', error = '', outcome = '', retry_allowed = 0, updated_at = ?
|
||||
WHERE device_id = ? AND client_msg_id = ?
|
||||
AND status = 'failed' AND retry_allowed = 1`,
|
||||
time.Now().Unix(), cmd.DeviceID, cmd.ClientMsgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
claimed, err := retry.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
stored, err = db.GetPromptCommand(cmd.DeviceID, cmd.ClientMsgID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return stored, claimed == 1, nil
|
||||
}
|
||||
|
||||
// MarkPromptForwarded records that the websocket write completed. From this
|
||||
// point a replay must query daemon journal state rather than resend execution.
|
||||
func (db *DB) MarkPromptForwarded(deviceID, clientMsgID string) (*PromptCommand, bool, error) {
|
||||
result, err := db.conn.Exec(`
|
||||
UPDATE prompt_commands
|
||||
SET status = 'pending', updated_at = ?
|
||||
WHERE device_id = ? AND client_msg_id = ? AND status = 'registered'`,
|
||||
time.Now().Unix(), deviceID, clientMsgID,
|
||||
)
|
||||
return db.promptTransitionResult(deviceID, clientMsgID, result, err)
|
||||
}
|
||||
|
||||
func (db *DB) GetPromptCommand(deviceID, clientMsgID string) (*PromptCommand, error) {
|
||||
var cmd PromptCommand
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
|
||||
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at
|
||||
FROM prompt_commands
|
||||
WHERE device_id = ? AND client_msg_id = ?`,
|
||||
deviceID, clientMsgID,
|
||||
).Scan(
|
||||
&cmd.DeviceID, &cmd.ClientMsgID, &cmd.SessionID, &cmd.Prompt,
|
||||
&cmd.AttachmentsJSON, &cmd.SealedEnvelopeJSON, &cmd.Status, &cmd.Error, &cmd.Outcome,
|
||||
&cmd.RetryAllowed, &cmd.CommitSent, &cmd.CreatedAt, &cmd.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cmd, nil
|
||||
}
|
||||
|
||||
// MarkPromptAccepted records the daemon's authoritative acceptance. It may
|
||||
// override a transport-level failure because websocket writes can fail after a
|
||||
// complete frame reached the daemon. Accepted itself is terminal.
|
||||
func (db *DB) MarkPromptAccepted(deviceID, clientMsgID string) (*PromptCommand, bool, error) {
|
||||
now := time.Now().Unix()
|
||||
result, err := db.conn.Exec(`
|
||||
UPDATE prompt_commands
|
||||
SET status = 'accepted', error = '', outcome = 'accepted',
|
||||
retry_allowed = 0, commit_sent = 0, updated_at = ?
|
||||
WHERE device_id = ? AND client_msg_id = ?
|
||||
AND status IN ('registered', 'pending', 'failed', 'indeterminate')`,
|
||||
now, deviceID, clientMsgID,
|
||||
)
|
||||
return db.promptTransitionResult(deviceID, clientMsgID, result, err)
|
||||
}
|
||||
|
||||
func (db *DB) MarkPromptCommitted(deviceID, clientMsgID string) error {
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE prompt_commands
|
||||
SET commit_sent = 1, updated_at = ?
|
||||
WHERE device_id = ? AND client_msg_id = ? AND status = 'accepted'`,
|
||||
time.Now().Unix(), deviceID, clientMsgID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListUncommittedAcceptedPrompts(deviceID string, limit int) ([]*PromptCommand, error) {
|
||||
if limit < 1 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
|
||||
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at
|
||||
FROM prompt_commands
|
||||
WHERE device_id = ? AND status = 'accepted' AND commit_sent = 0
|
||||
ORDER BY updated_at ASC, client_msg_id ASC
|
||||
LIMIT ?`,
|
||||
deviceID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var commands []*PromptCommand
|
||||
for rows.Next() {
|
||||
cmd := &PromptCommand{}
|
||||
if err := rows.Scan(
|
||||
&cmd.DeviceID, &cmd.ClientMsgID, &cmd.SessionID, &cmd.Prompt,
|
||||
&cmd.AttachmentsJSON, &cmd.SealedEnvelopeJSON, &cmd.Status, &cmd.Error, &cmd.Outcome,
|
||||
&cmd.RetryAllowed, &cmd.CommitSent, &cmd.CreatedAt, &cmd.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// MarkPromptFailed transitions a pending command to a terminal result. An
|
||||
// indeterminate outcome is deliberately never eligible for ordinary retry.
|
||||
func (db *DB) MarkPromptFailed(
|
||||
deviceID, clientMsgID, message, outcome string,
|
||||
retryAllowed bool,
|
||||
) (*PromptCommand, bool, error) {
|
||||
status := PromptFailed
|
||||
if outcome == PromptIndeterminate {
|
||||
status = PromptIndeterminate
|
||||
retryAllowed = false
|
||||
}
|
||||
if outcome == "" {
|
||||
outcome = PromptFailed
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
retryInt := 0
|
||||
if retryAllowed {
|
||||
retryInt = 1
|
||||
}
|
||||
var (
|
||||
result sql.Result
|
||||
err error
|
||||
)
|
||||
if status == PromptIndeterminate {
|
||||
// A late daemon journal result must be able to tighten an earlier
|
||||
// retryable transport failure.
|
||||
result, err = db.conn.Exec(`
|
||||
UPDATE prompt_commands
|
||||
SET status = 'indeterminate', error = ?, outcome = 'indeterminate',
|
||||
retry_allowed = 0, updated_at = ?
|
||||
WHERE device_id = ? AND client_msg_id = ?
|
||||
AND status IN ('registered', 'pending', 'failed')`,
|
||||
message, now, deviceID, clientMsgID,
|
||||
)
|
||||
} else {
|
||||
result, err = db.conn.Exec(`
|
||||
UPDATE prompt_commands
|
||||
SET status = ?, error = ?, outcome = ?, retry_allowed = ?, updated_at = ?
|
||||
WHERE device_id = ? AND client_msg_id = ?
|
||||
AND status IN ('registered', 'pending')`,
|
||||
status, message, outcome, retryInt, now, deviceID, clientMsgID,
|
||||
)
|
||||
}
|
||||
return db.promptTransitionResult(deviceID, clientMsgID, result, err)
|
||||
}
|
||||
|
||||
func (db *DB) promptTransitionResult(
|
||||
deviceID, clientMsgID string,
|
||||
result sql.Result,
|
||||
err error,
|
||||
) (*PromptCommand, bool, error) {
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
cmd, err := db.GetPromptCommand(deviceID, clientMsgID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return cmd, affected == 1, nil
|
||||
}
|
||||
|
||||
// PromptCommandCount is intentionally small and primarily useful for
|
||||
// diagnostics and focused idempotency tests.
|
||||
func (db *DB) PromptCommandCount(deviceID, clientMsgID string) (int, error) {
|
||||
var count int
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT COUNT(*) FROM prompt_commands
|
||||
WHERE device_id = ? AND client_msg_id = ?`,
|
||||
deviceID, clientMsgID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
corestore "github.com/klarkxy/nekonest/relaycore/store"
|
||||
)
|
||||
|
||||
const maxPushSubscriptionsPerDevice = 32
|
||||
|
||||
// PushSubscription represents a Web Push subscription.
|
||||
type PushSubscription = corestore.PushSubscription
|
||||
|
||||
// SavePushSubscription stores one endpoint mapping per device. A browser reuses
|
||||
// the same endpoint while the user subscribes to multiple devices.
|
||||
func (db *DB) SavePushSubscription(sub *PushSubscription) error {
|
||||
now := time.Now().Unix()
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec(`
|
||||
INSERT INTO push_subscriptions (device_id, phone_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint, device_id) DO UPDATE SET
|
||||
phone_id = excluded.phone_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
created_at = excluded.created_at`,
|
||||
sub.DeviceID, sub.PhoneID, sub.Endpoint, sub.P256DH, sub.Auth, now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE id IN (
|
||||
SELECT id FROM push_subscriptions
|
||||
WHERE device_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT -1 OFFSET ?
|
||||
)`,
|
||||
sub.DeviceID, maxPushSubscriptionsPerDevice,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetPushSubscriptions returns all subscriptions for a device.
|
||||
func (db *DB) GetPushSubscriptions(deviceID string) ([]*PushSubscription, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, device_id, phone_id, endpoint, p256dh, auth FROM push_subscriptions WHERE device_id = ?`,
|
||||
deviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []*PushSubscription
|
||||
for rows.Next() {
|
||||
sub := &PushSubscription{}
|
||||
if err := rows.Scan(&sub.ID, &sub.DeviceID, &sub.PhoneID, &sub.Endpoint, &sub.P256DH, &sub.Auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// DeletePushSubscription removes a subscription by endpoint.
|
||||
func (db *DB) DeletePushSubscription(endpoint string) error {
|
||||
_, err := db.conn.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func marshalJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func unmarshalJSON(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
Reference in New Issue
Block a user