feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/klarkxy/nekonest-cloud/relay/internal/auditsink"
"github.com/klarkxy/nekonest-cloud/relay/internal/config"
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
"github.com/klarkxy/nekonest-cloud/relay/internal/forwarder"
"github.com/klarkxy/nekonest-cloud/relay/internal/migration"
"github.com/klarkxy/nekonest-cloud/relay/internal/purge"
"github.com/klarkxy/nekonest-cloud/relay/internal/pushsink"
"github.com/klarkxy/nekonest-cloud/relay/internal/registry"
"github.com/klarkxy/nekonest-cloud/relay/internal/service"
)
var version = "dev"
func run() error {
configuration, err := config.Load()
if err != nil {
return err
}
internalForwarder, err := forwarder.New(forwarder.Config{
NodeID: configuration.NodeID, Endpoints: configuration.InternalEndpoints,
Secret: configuration.ForwardSecret,
ClientCertFile: configuration.ClientCertificate,
ClientKeyFile: configuration.ClientKey,
CAFile: configuration.InternalRelayCA,
})
if err != nil {
return err
}
control, err := controlplane.New(controlplane.Config{
BaseURL: configuration.ControlPlaneURL,
ClientCertFile: configuration.ClientCertificate,
ClientKeyFile: configuration.ClientKey,
CAFile: configuration.ControlPlaneCA,
})
if err != nil {
return err
}
engines, err := registry.New(registry.Config{
DataRoot: configuration.DataRoot,
NodeID: configuration.NodeID,
AllowedOrigins: configuration.AllowedPWAOrigins,
RouteSecret: configuration.RouteSecret,
AppVersion: version,
MaxTenants: configuration.MaxTenants,
Keyring: configuration.SnapshotKeys,
ControlPlane: control,
PushSink: pushsink.Sink{},
AuditSink: auditsink.Sink{Logger: slog.Default()},
})
if err != nil {
return err
}
defer engines.Close()
migrations, err := migration.New(migration.Config{
DataRoot: configuration.DataRoot, BackupRoot: configuration.BackupRoot,
Registry: engines, ControlPlane: control, Logger: slog.Default(),
})
if err != nil {
return err
}
defer migrations.Wait()
purges, err := purge.New(purge.Config{
DataRoot: configuration.DataRoot, BackupRoot: configuration.BackupRoot,
Registry: engines, ControlPlane: control, Logger: slog.Default(),
})
if err != nil {
return err
}
defer purges.Wait()
dataPlane, err := service.New(service.Config{
ControlPlane: control,
Registry: engines,
AllowedPWAOrigins: configuration.AllowedPWAOrigins,
SourceHashSecret: configuration.SourceHashSecret,
TrustedProxyRanges: configuration.TrustedProxyRanges,
AppVersion: version,
NodeID: configuration.NodeID,
ForwardSecret: configuration.ForwardSecret,
HandoffSecret: configuration.HandoffSecret,
Forwarder: internalForwarder,
})
if err != nil {
return err
}
server := &http.Server{
Addr: configuration.ListenAddress,
Handler: dataPlane.Handler(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 75 * time.Second,
MaxHeaderBytes: 32 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go heartbeatLoop(ctx, control, migrations, purges, configuration.MaxTenants)
result := make(chan error, 1)
go func() {
slog.Info("Cloud Relay listening", "address", configuration.ListenAddress, "node_id", configuration.NodeID, "version", version)
result <- server.ListenAndServe()
}()
select {
case err := <-result:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), configuration.ShutdownTimeout)
defer cancel()
return server.Shutdown(shutdownContext)
}
}
func heartbeatLoop(
ctx context.Context, control *controlplane.Client,
migrations *migration.Manager, purges *purge.Manager, capacity int,
) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
assignments, err := control.Heartbeat(requestContext, time.Now().UnixMilli(), capacity)
cancel()
if err != nil && ctx.Err() == nil {
slog.Warn("Relay heartbeat failed", "error", err)
} else if err == nil {
migrations.Handle(ctx, assignments.Migrations)
purges.Handle(ctx, assignments.Purges)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func main() {
if err := run(); err != nil {
slog.Error("Cloud Relay stopped", "error", err)
os.Exit(1)
}
}
+28
View File
@@ -0,0 +1,28 @@
module github.com/klarkxy/nekonest-cloud/relay
go 1.22
require (
github.com/gorilla/websocket v1.5.3
github.com/klarkxy/nekonest/relaycore v0.1.0
modernc.org/sqlite v1.29.0
)
require (
github.com/SherClockHolmes/webpush-go v1.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/sys v0.16.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+81
View File
@@ -0,0 +1,81 @@
github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.0 h1:lQVw+ZsFM3aRG5m4myG70tbXpr3S/J1ej0KHIP4EvjM=
modernc.org/sqlite v1.29.0/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+232
View File
@@ -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
}
+24
View File
@@ -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...)
}
+305
View File
@@ -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")
}
}
+330
View File
@@ -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
}
+59
View File
@@ -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)
}
}
}
+417
View File
@@ -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
}
+323
View File
@@ -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")
}
}
+175
View File
@@ -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() }
+103
View File
@@ -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)
}
}
+120
View File
@@ -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() }
+64
View File
@@ -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)
}
}
+33
View File
@@ -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)
}
+852
View File
@@ -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
}
+316
View File
@@ -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")
}
}
+847
View File
@@ -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))
}
+392
View File
@@ -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)
}
}
+593
View File
@@ -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")
}
}
+94
View File
@@ -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
}
+58
View File
@@ -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")
}
}
+128
View File
@@ -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
}
+77
View File
@@ -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)
}
}
+776
View File
@@ -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
}
+187
View File
@@ -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
}
+49
View File
@@ -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
}
+337
View File
@@ -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()
}
+122
View File
@@ -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
}
+76
View File
@@ -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
}
+29
View File
@@ -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)
}