diff --git a/.gitignore b/.gitignore index 46de91b..fd72b14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Binaries -server -verstak-sync-server +/server +/verstak-sync-server *.exe # Data directory diff --git a/go.mod b/go.mod index 54c865e..5245322 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module github.com/verstak/verstak-sync-server -go 1.24.4 +go 1.25.0 + +require ( + github.com/mattn/go-sqlite3 v1.14.46 // indirect + golang.org/x/crypto v0.53.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..afa91a7 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU= +github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..4e7565e --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,84 @@ +package server + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +type AdminUser struct { + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` +} + +type Config struct { + Port int `yaml:"port"` + Admin []AdminUser `yaml:"admin"` + mu sync.Mutex + path string +} + +func LoadConfig(dataDir string) (*Config, error) { + path := filepath.Join(dataDir, "config.yml") + cfg := &Config{ + Port: 47732, + Admin: nil, + path: path, + } + data, err := os.ReadFile(path) + if err == nil { + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + } + return cfg, nil +} + +func (c *Config) Save() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.saveLocked() +} + +func (c *Config) SetAdmin(username, password string) error { + c.mu.Lock() + defer c.mu.Unlock() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + user := AdminUser{Username: username, PasswordHash: string(hash)} + for i, u := range c.Admin { + if u.Username == username { + c.Admin[i] = user + return c.saveLocked() + } + } + c.Admin = append(c.Admin, user) + return c.saveLocked() +} + +func (c *Config) CheckAdmin(username, password string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, u := range c.Admin { + if u.Username == username { + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { + return true + } + } + } + return false +} + +func (c *Config) saveLocked() error { + data, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0640) +} diff --git a/internal/server/schema.go b/internal/server/schema.go new file mode 100644 index 0000000..73cad8c --- /dev/null +++ b/internal/server/schema.go @@ -0,0 +1,84 @@ +package server + +const serverSchema = ` +CREATE TABLE IF NOT EXISTS server_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + confirmed INTEGER NOT NULL DEFAULT 0, + blocked INTEGER NOT NULL DEFAULT 0, + last_seen TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + token_hash TEXT, + token_prefix TEXT, + token_suffix TEXT, + user_id TEXT, + client_version TEXT, + last_ip TEXT, + last_seen TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_user_devices ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + PRIMARY KEY (user_id, device_id) +); + +CREATE TABLE IF NOT EXISTS server_ops ( + op_id TEXT PRIMARY KEY, + server_sequence INTEGER, + device_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + idempotency_key TEXT, + client_sequence INTEGER DEFAULT 0, + last_seen_server_seq INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + pushed_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS server_tombstones ( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (entity_type, entity_id) +); + +CREATE TABLE IF NOT EXISTS server_idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + user_id TEXT, + device_id TEXT, + ip TEXT, + message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence); +CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_server_ops_idempotency ON server_ops(idempotency_key); +CREATE INDEX IF NOT EXISTS idx_server_devices_api_key ON server_devices(api_key); +CREATE INDEX IF NOT EXISTS idx_server_devices_user ON server_devices(user_id); +CREATE INDEX IF NOT EXISTS idx_server_users_username ON server_users(username); +CREATE INDEX IF NOT EXISTS idx_server_users_email ON server_users(email); +CREATE INDEX IF NOT EXISTS idx_server_audit_log_event ON server_audit_log(event_type); +CREATE INDEX IF NOT EXISTS idx_server_audit_log_created ON server_audit_log(created_at); +` diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..a4fe072 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,167 @@ +package server + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type pairRateLimit struct { + mu sync.Mutex + attempts map[string]int +} + +func (p *pairRateLimit) allow(ip string) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.attempts == nil { + p.attempts = make(map[string]int) + } + p.attempts[ip]++ + return p.attempts[ip] <= 5 +} + +func (p *pairRateLimit) reset(ip string) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.attempts, ip) +} + +type tokenStore struct { + mu sync.Mutex + tokens map[string]time.Time +} + +func newTokenStore() *tokenStore { + return &tokenStore{tokens: make(map[string]time.Time)} +} + +func (ts *tokenStore) Create() string { + ts.mu.Lock() + defer ts.mu.Unlock() + b := make([]byte, 16) + rand.Read(b) + tok := hex.EncodeToString(b) + ts.tokens[tok] = time.Now().Add(24 * time.Hour) + return tok +} + +func (ts *tokenStore) Check(tok string) bool { + ts.mu.Lock() + defer ts.mu.Unlock() + exp, ok := ts.tokens[tok] + if !ok { + return false + } + if time.Now().After(exp) { + delete(ts.tokens, tok) + return false + } + return true +} + +type userTokenStore struct { + mu sync.Mutex + tokens map[string]userTokenEntry +} + +type userTokenEntry struct { + UserID string + ExpiresAt time.Time +} + +func newUserTokenStore() *userTokenStore { + return &userTokenStore{tokens: make(map[string]userTokenEntry)} +} + +func (uts *userTokenStore) Create(userID string) string { + uts.mu.Lock() + defer uts.mu.Unlock() + b := make([]byte, 16) + rand.Read(b) + tok := hex.EncodeToString(b) + uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)} + return tok +} + +func (uts *userTokenStore) Check(tok string) (string, bool) { + uts.mu.Lock() + defer uts.mu.Unlock() + entry, ok := uts.tokens[tok] + if !ok { + return "", false + } + if time.Now().After(entry.ExpiresAt) { + delete(uts.tokens, tok) + return "", false + } + return entry.UserID, true +} + +type Server struct { + db *sql.DB + cfg *Config + tokens *tokenStore + userTokens *userTokenStore + blobsDir string + mux *http.ServeMux + pairLimit *pairRateLimit +} + +func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) { + s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)", + eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339)) +} + +func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath)) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + db.SetMaxOpenConns(1) + + for _, stmt := range strings.Split(serverSchema, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.Exec(stmt); err != nil { + db.Close() + return nil, fmt.Errorf("schema: %w", err) + } + } + + blobsDir := filepath.Join(dataDir, "blobs") + if err := os.MkdirAll(blobsDir, 0750); err != nil { + db.Close() + return nil, err + } + + s := &Server{ + db: db, + cfg: cfg, + tokens: newTokenStore(), + userTokens: newUserTokenStore(), + blobsDir: blobsDir, + pairLimit: &pairRateLimit{}, + } + s.mux = http.NewServeMux() + return s, nil +} + +func (s *Server) Close() error { + return s.db.Close() +} + +func (s *Server) ListenAndServe(addr string) error { + return http.ListenAndServe(addr, s.mux) +}