feat: add server core (server, config, schema)
This commit is contained in:
parent
69dacb40b0
commit
e0ae36998e
|
|
@ -1,6 +1,6 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
server
|
/server
|
||||||
verstak-sync-server
|
/verstak-sync-server
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# Data directory
|
# Data directory
|
||||||
|
|
|
||||||
8
go.mod
8
go.mod
|
|
@ -1,3 +1,9 @@
|
||||||
module github.com/verstak/verstak-sync-server
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
`
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue