Harden sync server transport and credentials

This commit is contained in:
mirivlad 2026-07-17 05:09:28 +08:00
parent 487ede8e4f
commit b7f730cba9
33 changed files with 3047 additions and 594 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
/server
/verstak-sync-server
*.exe
/build/
# Data directory
server-data/

117
README.md
View File

@ -8,7 +8,7 @@ This server provides synchronization between devices running Verstak2. It handle
- Device registration and authentication
- Vault-scoped, ordered operation-log relay with server sequence numbers
- Optional blob endpoints, not used by the current bounded Desktop file sync
- Scoped content-addressed Blob transport for binary and large file content
- User management with email confirmation
## Quick Start
@ -18,10 +18,12 @@ This server provides synchronization between devices running Verstak2. It handle
./scripts/build.sh
# Run
./build/bin/verstak-sync-server --port 47732 --data ./server-data
./build/bin/verstak-sync-server --data ./server-data
# First run with admin user
./build/bin/verstak-sync-server --admin-user admin --admin-pass secret
printf '%s\n' 'choose-a-long-password' > /tmp/verstak-admin-password
chmod 600 /tmp/verstak-admin-password
./build/bin/verstak-sync-server --admin-user admin --admin-pass-file /tmp/verstak-admin-password
```
## Release packages
@ -51,16 +53,43 @@ annotated tag when necessary, then creates or updates the GitHub Release.
| Flag | Default | Description |
|------|---------|-------------|
| `--port` | 47732 | HTTP port |
| `--listen` | `127.0.0.1:47732` | HTTP address; an administrator must explicitly expose another interface |
| `--port` | — | Deprecated compatibility shortcut; always binds loopback |
| `--data` | ./server-data | Data directory |
| `--admin-user` | | Create admin user (first run) |
| `--admin-pass` | | Admin password (first run) |
| `--admin-pass-file` | | Read the initial admin password from a protected file |
| `--admin-pass-stdin` | | Read the initial admin password from stdin |
The server has explicit header/read/write/idle timeouts and a 16 KiB header
limit. It handles SIGINT/SIGTERM with a 20-second graceful shutdown and closes
SQLite afterwards. Release builds publish `version` and `build_commit` through
the health response; neither logs nor health contain credentials.
`config.yml` can set `listen`, `public_url`, `trusted_proxies`, and limits:
```yaml
listen: 127.0.0.1:47732
public_url: https://sync.example.test
trusted_proxies: [127.0.0.1, ::1]
limits:
max_json_body: 2097152
max_push_operations: 100
max_payload_json: 262144
max_pull_page: 100
max_blob_bytes: 268435456
max_vault_blob_bytes: 4294967296
max_user_blob_bytes: 8589934592
retention:
idempotency_hours: 24
audit_days: 90
temp_upload_hours: 24
```
Production installs use:
- binary: `/opt/verstak-sync-server/verstak-sync-server`;
- data directory: `/var/lib/verstak-sync-server`;
- port environment file: `/etc/verstak-server/env`;
- listen-address environment file: `/etc/verstak-server/env`;
- service: `verstak-server`.
Install from a built binary:
@ -69,9 +98,9 @@ Install from a built binary:
./scripts/build.sh
sudo ./scripts/install.sh \
--bin ./build/bin/verstak-sync-server \
--port 47732 \
--listen 127.0.0.1:47732 \
--admin-user admin \
--admin-pass 'change-this-password'
--admin-pass-file /root/verstak-admin-password
```
The install script creates a locked-down system user, initializes the data
@ -95,7 +124,7 @@ curl http://127.0.0.1:47732/api/v1/health
Change the listen port:
```bash
echo 'VERSTAK_PORT=47733' | sudo tee /etc/verstak-server/env
echo 'VERSTAK_LISTEN=127.0.0.1:47733' | sudo tee /etc/verstak-server/env
sudo systemctl restart verstak-server
```
@ -139,7 +168,7 @@ sudo tar --xattrs --acls -xzf verstak-sync-backup-YYYYMMDD-HHMMSS.tar.gz -C /var
sudo chown -R verstak:verstak /var/lib/verstak-sync-server
sudo chmod 750 /var/lib/verstak-sync-server
sudo systemctl start verstak-server
curl http://127.0.0.1:${VERSTAK_PORT:-47732}/api/v1/health
curl http://127.0.0.1:47732/api/v1/health
```
After restore, connected desktop clients keep their existing device tokens.
@ -176,7 +205,7 @@ Desktop sync client:
User API:
- `POST /api/v1/auth/register` - Register a user
- `GET /api/v1/auth/confirm?token=...` - Confirm email
- `GET /api/v1/auth/confirm?token=...` - Display a confirmation form; `POST` performs confirmation
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/forgot` - Request password reset
- `POST /api/v1/auth/reset` - Reset password
@ -200,11 +229,67 @@ does not merge files, resolve conflicts, or create replacement names.
The desktop pairing payload may supply an existing `vault_id` to add a new
empty local vault to that remote scope. The server treats that value only as a
scope selector: reconciliation, conflict detection, snapshots, and durable
workspace identity remain Desktop-core responsibilities. Current Desktop core
uses bounded inline file payloads (text or base64 up to 8 MB) and workspace
operations (`create`, `rename`, `trash`, `restore`). Blob transport, quotas,
pull pagination, and operation retention are a later milestone; the existing
blob endpoints must not be interpreted as enabled Desktop large-file sync.
workspace identity remain Desktop-core responsibilities. Small text can remain
inline; binary and large files are uploaded first and their operations carry a
`blob` `{sha256,size}` reference. Blob bytes are physically deduplicated but a
`user_id`/`vault_id` reference is mandatory. Knowing another scope's SHA-256
never grants download access. Revoked devices and blocked users lose sync and
blob access immediately.
`POST /api/v1/sync/pull` accepts `since_sequence` and optional `page_limit`.
The response has ordered `ops`, `page_last_sequence`, `server_sequence`, and
`has_more`. Clients must persist a cursor only after applying each operation.
Push is capped by the configured JSON/body/field limits and returns stable
JSON `{error,code}` errors (including `request_too_large`, `rate_limited`, and
`quota_exceeded`); desktop and UI localize codes rather than server text.
New device tokens, sessions, and email/reset tokens are stored only as SHA-256
hashes. The plaintext device token is returned once by pairing. Older plaintext
API keys are marked `legacy_api_key=1` during migration and are never created
again; rotate/re-pair them during normal deployment. Admin key endpoints show
only a prefix/suffix hint. Sessions are database-backed, expire after 24 hours,
rotate on login, and use HttpOnly/Lax cookies plus a SameSite/CSRF companion
cookie. Mutating browser endpoints require a matching CSRF token and do not use
GET for destructive work.
### Reverse proxy and TLS
TLS terminates at nginx or Caddy; the server has no built-in TLS. It ignores
`Forwarded`, `X-Forwarded-For`, and `X-Forwarded-Proto` unless the TCP peer is
listed in `trusted_proxies`. For a local nginx/Caddy proxy use
`trusted_proxies: [127.0.0.1, ::1]` and set `public_url` to the HTTPS URL.
```nginx
location / {
proxy_pass http://127.0.0.1:47732;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Never bind `0.0.0.0` merely to make a proxy work. If an external listener is
intentional, restrict it with a firewall and configure the real proxy CIDR.
### Operations, retention, and privacy
`GET /api/v1/health`, `/livez`, and `/readyz` report status, version, build,
uptime, database reachability, blob writability, schema version, and server
time without paths or secrets. The internal stats service exposes user/device,
vault, operation, database/blob size, and last-sync counters for a future
admin panel.
Retention cleans expired sessions/email tokens, bounded idempotency records,
old audit entries, stale upload temp files, and in-memory rate buckets. It does
**not** delete sync operations or referenced blobs: without a materialized
checkpoint and verified recovery protocol, pruning would prevent a new device
from restoring a vault. That checkpoint/operation-retention design is a future
milestone.
The server is optional: Desktop remains local-first. The relay can see metadata
and file bytes needed to serve operations/blobs; files are not end-to-end
encrypted in this milestone. Secrets, plugin settings, Todo, Journal,
Activity, and Browser Inbox are not synchronized here.
New device enrollment requires a non-empty `vault_id`. The `legacy:` prefix is
reserved for server-side migration of older records and cannot be selected by

Binary file not shown.

View File

@ -1,21 +1,36 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/verstak/verstak-sync-server/internal/server"
)
func main() {
port := flag.Int("port", 47732, "HTTP port")
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
listen := flag.String("listen", "", "HTTP listen address (default 127.0.0.1:47732)")
port := flag.Int("port", 0, "Deprecated compatibility override for the loopback port")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
adminPassFile := flag.String("admin-pass-file", "", "Read initial admin password from a 0600 file")
adminPassStdin := flag.Bool("admin-pass-stdin", false, "Read initial admin password from stdin")
showVersion := flag.Bool("version", false, "Print build version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("verstak-sync-server %s (%s)\n", server.Version, server.BuildCommit)
return
}
absData, err := filepath.Abs(*dataDir)
if err != nil {
@ -31,11 +46,37 @@ func main() {
log.Fatalf("config: %v", err)
}
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
if envListen := strings.TrimSpace(os.Getenv("VERSTAK_LISTEN")); envListen != "" {
cfg.Listen = envListen
}
if *listen != "" {
cfg.Listen = *listen
}
if *port != 0 && *listen == "" {
cfg.Listen = fmt.Sprintf("127.0.0.1:%d", *port)
}
if publicURL := strings.TrimSpace(os.Getenv("VERSTAK_PUBLIC_URL")); publicURL != "" {
cfg.PublicURL = publicURL
}
if trusted := strings.TrimSpace(os.Getenv("VERSTAK_TRUSTED_PROXIES")); trusted != "" {
cfg.TrustedProxies = strings.Split(trusted, ",")
}
if err := cfg.Normalize(); err != nil {
log.Fatalf("config: %v", err)
}
adminPass, err := initialAdminPassword(*adminPassFile, *adminPassStdin)
if err != nil {
log.Fatalf("admin password: %v", err)
}
if (*adminUser == "") != (adminPass == "") {
log.Fatal("admin-user and one admin password source must be supplied together")
}
if *adminUser != "" {
if err := cfg.SetAdmin(*adminUser, adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
log.Printf("initial admin user %q configured", *adminUser)
}
dbPath := filepath.Join(absData, "server.db")
@ -43,13 +84,64 @@ func main() {
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
srv.SetupRoutes()
if err := srv.CleanupRetention(time.Now().UTC()); err != nil {
_ = srv.Close()
log.Fatalf("retention cleanup: %v", err)
}
addr := cfg.ListenAddress()
listener, err := net.Listen("tcp", addr)
if err != nil {
_ = srv.Close()
log.Fatalf("listen %s: %v", addr, err)
}
httpServer := srv.HTTPServer(addr)
serveDone := make(chan error, 1)
go func() { serveDone <- httpServer.Serve(listener) }()
log.Printf("Verstak Sync Server %s (%s) listening on %s", server.Version, server.BuildCommit, addr)
addr := fmt.Sprintf(":%d", *port)
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatalf("serve: %v", err)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-signals:
log.Printf("received %s; shutting down", sig)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
err = httpServer.Shutdown(shutdownCtx)
cancel()
if err != nil {
log.Printf("graceful shutdown: %v", err)
}
case err = <-serveDone:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
_ = srv.Close()
log.Fatalf("serve: %v", err)
}
}
if err := srv.Close(); err != nil {
log.Printf("close database: %v", err)
}
}
func initialAdminPassword(path string, useStdin bool) (string, error) {
if path != "" && useStdin {
return "", fmt.Errorf("choose either --admin-pass-file or --admin-pass-stdin")
}
if path == "" && !useStdin {
return "", nil
}
var data []byte
var err error
if path != "" {
data, err = os.ReadFile(path)
} else {
data, err = os.ReadFile("/dev/stdin")
}
if err != nil {
return "", err
}
password := strings.TrimSpace(string(data))
if password == "" {
return "", fmt.Errorf("password source is empty")
}
return password, nil
}

277
internal/server/blobs.go Normal file
View File

@ -0,0 +1,277 @@
package server
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 || strings.ToLower(value) != value {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func blobPath(root, hash string) string {
return filepath.Join(root, hash[:2], hash[2:4], hash)
}
func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request, scope authenticatedDevice) {
// Reserve a little multipart framing headroom while the file itself is
// measured independently. The stream is never materialized in memory.
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxBlobBytes+(1<<20))
reader, err := r.MultipartReader()
if err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "invalid multipart request")
return
}
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "file field is required")
return
}
if err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "invalid multipart request")
return
}
defer part.Close()
if part.FormName() != "file" || part.FileName() == "" {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "exactly one file field is required")
return
}
tmp, err := os.CreateTemp(s.blobsDir, ".upload-*")
if err != nil {
jsonInternalError(w, err)
return
}
tmpName := tmp.Name()
cleanupTmp := true
defer func() {
if cleanupTmp {
_ = os.Remove(tmpName)
}
}()
if err := tmp.Chmod(0640); err != nil {
_ = tmp.Close()
jsonInternalError(w, err)
return
}
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(part, s.cfg.Limits.MaxBlobBytes+1))
if err != nil {
_ = tmp.Close()
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "blob_too_large", "blob is too large")
} else {
jsonInternalError(w, err)
}
return
}
if written > s.cfg.Limits.MaxBlobBytes {
_ = tmp.Close()
jsonErrCode(w, http.StatusRequestEntityTooLarge, "blob_too_large", "blob is too large")
return
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
jsonInternalError(w, err)
return
}
if err := tmp.Close(); err != nil {
jsonInternalError(w, err)
return
}
// Do not silently accept a second part: this endpoint has one unambiguous
// file contract and no form fields that could hide extra request data.
if extra, err := reader.NextPart(); err != io.EOF {
if extra != nil {
_ = extra.Close()
}
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "exactly one file field is required")
return
}
sha := hex.EncodeToString(hash.Sum(nil))
if err := s.attachBlob(scope.UserID, scope.VaultID, sha, written, tmpName); err != nil {
if errors.Is(err, errBlobTooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "quota_exceeded", "blob quota exceeded")
return
}
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"sha256": sha, "size": written})
}
var errBlobTooLarge = errors.New("blob quota exceeded")
// attachBlob checks logical quotas before the rename, then atomically makes
// the content reachable and commits the scope reference. A DB error removes a
// newly-created physical file so rejected requests leave no blob behind.
func (s *Server) attachBlob(userID, vaultID, sha string, size int64, tmpName string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var existingSize int64
err = tx.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`, userID, vaultID, sha).Scan(&existingSize)
if err == nil {
if existingSize != size {
return fmt.Errorf("existing blob reference has a different size")
}
if _, err := tx.Exec(`UPDATE server_blob_refs SET last_accessed=? WHERE user_id=? AND vault_id=? AND sha256=?`, time.Now().UTC().Format(time.RFC3339), userID, vaultID, sha); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var vaultUsed, userUsed int64
if err := tx.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&vaultUsed); err != nil {
return err
}
if err := tx.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM server_blob_refs WHERE user_id=?`, userID).Scan(&userUsed); err != nil {
return err
}
if vaultUsed+size > s.cfg.Limits.MaxVaultBlobBytes || userUsed+size > s.cfg.Limits.MaxUserBlobBytes {
return errBlobTooLarge
}
destination := blobPath(s.blobsDir, sha)
if err := os.MkdirAll(filepath.Dir(destination), 0750); err != nil {
return err
}
createdPhysical := false
if _, err := os.Stat(destination); errors.Is(err, os.ErrNotExist) {
if err := os.Rename(tmpName, destination); err != nil {
return err
}
createdPhysical = true
} else if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := tx.Exec(`INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)`, sha, size, now); err != nil {
if createdPhysical {
_ = os.Remove(destination)
}
return err
}
if _, err := tx.Exec(`INSERT INTO server_blob_refs (user_id, vault_id, sha256, size, created_at, last_accessed) VALUES (?, ?, ?, ?, ?, ?)`, userID, vaultID, sha, size, now, now); err != nil {
if createdPhysical {
_ = os.Remove(destination)
}
return err
}
if err := tx.Commit(); err != nil {
if createdPhysical {
_ = os.Remove(destination)
}
return err
}
return nil
}
func (s *Server) handleBlobDownload(w http.ResponseWriter, r *http.Request, scope authenticatedDevice, sha string) {
if !validSHA256(sha) {
jsonErrCode(w, http.StatusBadRequest, "invalid_blob_hash", "invalid SHA-256")
return
}
var size int64
err := s.db.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`, scope.UserID, scope.VaultID, sha).Scan(&size)
if errors.Is(err, sql.ErrNoRows) {
jsonErrCode(w, http.StatusNotFound, "blob_not_found", "blob not found")
return
}
if err != nil {
jsonInternalError(w, err)
return
}
file, err := os.Open(blobPath(s.blobsDir, sha))
if errors.Is(err, os.ErrNotExist) {
jsonErrCode(w, http.StatusNotFound, "blob_not_found", "blob not found")
return
}
if err != nil {
jsonInternalError(w, err)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
jsonInternalError(w, err)
return
}
if !info.Mode().IsRegular() || info.Size() != size {
jsonErrCode(w, http.StatusInternalServerError, "blob_unavailable", "blob is unavailable")
return
}
if _, err := s.db.Exec(`UPDATE server_blob_refs SET last_accessed=? WHERE user_id=? AND vault_id=? AND sha256=?`, time.Now().UTC().Format(time.RFC3339), scope.UserID, scope.VaultID, sha); err != nil {
jsonInternalError(w, err)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Content-Disposition", "attachment; filename=\""+sha+"\"")
// ServeContent streams from the file and provides safe Range support.
http.ServeContent(w, r, sha, info.ModTime(), file)
}
// validateScopedBlobReferences ensures an accepted operation can later be
// restored by every authorized device in this scope. Legacy base64 binary
// payloads are deliberately rejected: binary content belongs in Blob storage,
// never in the operation log.
func validateScopedBlobReferences(tx *sql.Tx, scope authenticatedDevice, ops []syncPushOperation) (code, message string, err error) {
for _, op := range ops {
if op.EntityType != "file" || op.PayloadJSON == "" {
continue
}
var payload struct {
DataBase64 *string `json:"dataBase64"`
Blob *struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
} `json:"blob"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return "invalid_payload", "operation payload_json must be valid JSON", nil
}
if payload.DataBase64 != nil {
return "inline_binary_forbidden", "binary file payload must reference a blob", nil
}
if payload.Blob == nil {
continue
}
if !validSHA256(payload.Blob.SHA256) || payload.Blob.Size < 0 {
return "invalid_blob_reference", "invalid blob reference", nil
}
var storedSize int64
err := tx.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`,
scope.UserID, scope.VaultID, payload.Blob.SHA256).Scan(&storedSize)
if errors.Is(err, sql.ErrNoRows) {
return "blob_not_owned", "blob must be uploaded before its operation", nil
}
if err != nil {
return "", "", err
}
if storedSize != payload.Blob.Size {
return "invalid_blob_reference", "blob size does not match uploaded content", nil
}
}
return "", "", nil
}

View File

@ -2,8 +2,10 @@ package server
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
@ -16,28 +18,178 @@ type AdminUser struct {
}
type Config struct {
Port int `yaml:"port"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
// Port remains readable for older config.yml files. New deployments use
// Listen so an administrator must deliberately expose a non-loopback
// listener.
Port int `yaml:"port,omitempty"`
Listen string `yaml:"listen,omitempty"`
TrustedProxies []string `yaml:"trusted_proxies,omitempty"`
PublicURL string `yaml:"public_url,omitempty"`
DevelopmentTokenLogging bool `yaml:"development_token_logging,omitempty"`
Limits Limits `yaml:"limits,omitempty"`
Retention Retention `yaml:"retention,omitempty"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
trustedProxyPrefixes []netip.Prefix
}
// Retention controls data that has no role in reconstructing a vault. Sync
// operations and referenced blobs are deliberately absent: pruning either
// requires a checkpoint protocol or risks making a new device unrecoverable.
type Retention struct {
IdempotencyHours int `yaml:"idempotency_hours"`
AuditDays int `yaml:"audit_days"`
TempUploadHours int `yaml:"temp_upload_hours"`
}
// Limits protect the process and operation log independently of a client
// supplied value. They are deliberately conservative defaults for a
// self-hosted service and can be adjusted in config.yml.
type Limits struct {
MaxJSONBody int64 `yaml:"max_json_body"`
MaxPushOperations int `yaml:"max_push_operations"`
MaxPayloadJSON int `yaml:"max_payload_json"`
MaxPullPage int `yaml:"max_pull_page"`
MaxBlobBytes int64 `yaml:"max_blob_bytes"`
MaxVaultBlobBytes int64 `yaml:"max_vault_blob_bytes"`
MaxUserBlobBytes int64 `yaml:"max_user_blob_bytes"`
}
func defaultLimits() Limits {
return Limits{
MaxJSONBody: 2 << 20,
MaxPushOperations: 100,
MaxPayloadJSON: 256 << 10,
MaxPullPage: 100,
MaxBlobBytes: 256 << 20,
MaxVaultBlobBytes: 4 << 30,
MaxUserBlobBytes: 8 << 30,
}
}
// DefaultConfig is the safe baseline used by a new server and tests.
func DefaultConfig() *Config {
return &Config{
Port: 47732,
Listen: "127.0.0.1:47732",
Limits: defaultLimits(),
Retention: Retention{IdempotencyHours: 24, AuditDays: 90, TempUploadHours: 24},
}
}
func LoadConfig(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, "config.yml")
cfg := &Config{
Port: 47732,
Admin: nil,
path: path,
}
cfg := DefaultConfig()
cfg.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)
}
}
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("read config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// ListenAddress returns a loopback address unless an administrator explicitly
// configured another address. A legacy port-only config is also loopback.
func (c *Config) ListenAddress() string {
if c == nil {
return "127.0.0.1:47732"
}
if listen := strings.TrimSpace(c.Listen); listen != "" {
return listen
}
port := c.Port
if port == 0 {
port = 47732
}
return fmt.Sprintf("127.0.0.1:%d", port)
}
func (c *Config) normalize() error {
if c.Port == 0 {
c.Port = 47732
}
if strings.TrimSpace(c.Listen) == "" {
c.Listen = fmt.Sprintf("127.0.0.1:%d", c.Port)
}
defaults := defaultLimits()
if c.Limits.MaxJSONBody <= 0 {
c.Limits.MaxJSONBody = defaults.MaxJSONBody
}
if c.Limits.MaxPushOperations <= 0 {
c.Limits.MaxPushOperations = defaults.MaxPushOperations
}
if c.Limits.MaxPayloadJSON <= 0 {
c.Limits.MaxPayloadJSON = defaults.MaxPayloadJSON
}
if c.Limits.MaxPullPage <= 0 {
c.Limits.MaxPullPage = defaults.MaxPullPage
}
if c.Limits.MaxBlobBytes <= 0 {
c.Limits.MaxBlobBytes = defaults.MaxBlobBytes
}
if c.Limits.MaxVaultBlobBytes <= 0 {
c.Limits.MaxVaultBlobBytes = defaults.MaxVaultBlobBytes
}
if c.Limits.MaxUserBlobBytes <= 0 {
c.Limits.MaxUserBlobBytes = defaults.MaxUserBlobBytes
}
if c.Retention.IdempotencyHours <= 0 {
c.Retention.IdempotencyHours = 24
}
if c.Retention.AuditDays <= 0 {
c.Retention.AuditDays = 90
}
if c.Retention.TempUploadHours <= 0 {
c.Retention.TempUploadHours = 24
}
prefixes := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
addr, addrErr := netip.ParseAddr(raw)
if addrErr != nil {
return fmt.Errorf("trusted proxy %q is neither an IP address nor CIDR: %w", raw, err)
}
bits := 32
if addr.Is6() {
bits = 128
}
prefix = netip.PrefixFrom(addr, bits)
}
prefixes = append(prefixes, prefix.Masked())
}
c.trustedProxyPrefixes = prefixes
return nil
}
// Normalize applies safe defaults and validates proxy configuration after
// command-line and environment overrides.
func (c *Config) Normalize() error {
return c.normalize()
}
func (c *Config) isTrustedProxy(addr netip.Addr) bool {
for _, prefix := range c.trustedProxyPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
@ -80,5 +232,29 @@ func (c *Config) saveLocked() error {
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
if err := os.MkdirAll(filepath.Dir(c.path), 0750); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(c.path), ".config-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0640); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, c.path)
}

76
internal/server/csrf.go Normal file
View File

@ -0,0 +1,76 @@
package server
import (
"crypto/subtle"
"net/http"
"net/url"
"strings"
)
func (s *Server) requireAdminMutation(w http.ResponseWriter, r *http.Request) bool {
session, ok := s.requireSession(w, r, sessionScopeAdmin)
if !ok {
return false
}
return s.verifyCSRF(w, r, session)
}
func (s *Server) requireUserMutation(w http.ResponseWriter, r *http.Request) bool {
session, ok := s.requireSession(w, r, sessionScopeUser)
if !ok {
return false
}
return s.verifyCSRF(w, r, session)
}
func (s *Server) verifyCSRF(w http.ResponseWriter, r *http.Request, session webSession) bool {
cookie, err := r.Cookie("csrf_token")
if err != nil || cookie.Value == "" {
jsonErrCode(w, http.StatusForbidden, "csrf_invalid", "CSRF token is required")
return false
}
candidate := r.Header.Get("X-CSRF-Token")
if candidate == "" {
// Form parsing is only needed for regular HTML form posts. JSON callers
// must send the header, avoiding any interference with their decoder.
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") || strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
if err := r.ParseForm(); err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
return false
}
candidate = r.FormValue("csrf_token")
}
}
if candidate == "" || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) != 1 || subtle.ConstantTimeCompare([]byte(sha256Hex(candidate)), []byte(session.CSRFHash)) != 1 {
jsonErrCode(w, http.StatusForbidden, "csrf_invalid", "CSRF token is invalid")
return false
}
if !s.sameOrigin(r) {
jsonErrCode(w, http.StatusForbidden, "csrf_invalid", "request origin is not allowed")
return false
}
return true
}
func (s *Server) sameOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
// Older same-origin form submissions can omit Origin. The CSRF token is
// still mandatory, so accepting this remains protected.
return true
}
originURL, err := url.Parse(origin)
if err != nil || originURL.Host == "" {
return false
}
expected := strings.TrimSpace(s.cfg.PublicURL)
if expected != "" {
expectedURL, err := url.Parse(expected)
return err == nil && strings.EqualFold(originURL.Scheme, expectedURL.Scheme) && strings.EqualFold(originURL.Host, expectedURL.Host)
}
scheme := "http"
if s.requestIsHTTPS(r) {
scheme = "https"
}
return strings.EqualFold(originURL.Scheme, scheme) && strings.EqualFold(originURL.Host, r.Host)
}

View File

@ -0,0 +1,41 @@
package server
import (
"fmt"
)
func validatePairRequest(login, deviceName, clientVersion, vaultID string) error {
for _, field := range []struct {
name string
value string
max int
}{
{"login", login, maxLoginLength},
{"device name", deviceName, maxDeviceNameLength},
{"client version", clientVersion, maxClientVersionLength},
{"vault id", vaultID, maxVaultIDLength},
} {
if err := validateStringLength(field.name, field.value, field.max); err != nil {
return err
}
}
if vaultID == "" {
return fmt.Errorf("vault_id required")
}
return nil
}
func (s *Server) revokeDevice(deviceID, when string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", when, deviceID); err != nil {
return err
}
if _, err := tx.Exec("DELETE FROM server_sessions WHERE subject_id=? AND scope='device'", deviceID); err != nil {
return err
}
return tx.Commit()
}

View File

@ -0,0 +1,26 @@
package server
import (
"database/sql"
"time"
)
type sqlExecutor interface {
Exec(query string, args ...interface{}) (sql.Result, error)
}
func issueEmailToken(exec sqlExecutor, userID, purpose string, lifetime time.Duration) (string, error) {
token, err := randomSecret(24)
if err != nil {
return "", err
}
now := time.Now().UTC()
_, err = exec.Exec(`INSERT INTO server_email_tokens (token_hash, user_id, purpose, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`, sha256Hex(token), userID, purpose, now.Add(lifetime).Format(time.RFC3339), now.Format(time.RFC3339))
if err != nil {
return "", err
}
return token, nil
}
func emailTokenHash(token string) string { return sha256Hex(token) }

View File

@ -35,15 +35,19 @@ button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-r
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.allowRate(w, r, "login", user) {
return
}
if !s.cfg.CheckAdmin(user, pass) {
http.Error(w, "401 Unauthorized", 401)
return
}
tok := s.tokens.Create()
http.SetCookie(w, &http.Cookie{
Name: "admin_session", Value: tok, Path: "/admin",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400,
})
tok, csrf, err := s.createSession(sessionScopeAdmin, user)
if err != nil {
jsonInternalError(w, err)
return
}
s.setSessionCookies(w, r, sessionScopeAdmin, tok, csrf)
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", 405)
@ -51,6 +55,10 @@ button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-r
}
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -77,6 +85,10 @@ a{color:#4ecca3}</style></head><body>
}
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -123,6 +135,10 @@ td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head
}
func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -159,16 +175,8 @@ td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head
}
func (s *Server) requireAdminCookie(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie("admin_session")
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
if !s.tokens.Check(cookie.Value) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
return true
_, ok := s.requireSession(w, r, sessionScopeAdmin)
return ok
}
func intToStr(n int) string {
@ -177,20 +185,27 @@ func intToStr(n int) string {
}
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
var opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
jsonOK(w, map[string]int{"ops": opsCount})
stats, err := s.Stats(r.Context())
if err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, stats)
}
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if !s.requireAdminMutation(w, r) {
return
}
var req struct {
@ -229,6 +244,10 @@ func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPIDevices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -267,7 +286,7 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
}
switch r.Method {
case "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
rows, err := s.db.Query("SELECT id, name, COALESCE(token_prefix,''), COALESCE(token_suffix,'') FROM server_devices ORDER BY created_at")
if err != nil {
jsonInternalError(w, err)
return
@ -275,9 +294,12 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
var out []map[string]string
for rows.Next() {
var id, name, key string
rows.Scan(&id, &name, &key)
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
var id, name, prefix, suffix string
if err := rows.Scan(&id, &name, &prefix, &suffix); err != nil {
jsonInternalError(w, err)
return
}
out = append(out, map[string]string{"id": id, "name": name, "token_hint": prefix + "…" + suffix})
}
jsonOK(w, out)
default:
@ -286,11 +308,11 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if !s.requireAdminMutation(w, r) {
return
}
if err := r.ParseForm(); err != nil {
@ -307,24 +329,40 @@ func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "DELETE" {
methodNotAllowed(w, "DELETE")
return
}
if r.Method != "DELETE" {
jsonErr(w, 405, "DELETE required")
if !s.requireAdminMutation(w, r) {
return
}
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM server_user_devices WHERE device_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("DELETE FROM server_devices WHERE id=?", id); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -410,7 +448,11 @@ func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" && r.Method != "DELETE" {
methodNotAllowed(w, "POST", "DELETE")
return
}
if !s.requireAdminMutation(w, r) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/admin/api/users/")
@ -419,23 +461,52 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
id := strings.TrimSuffix(path, "/block")
id = strings.TrimSuffix(id, "/")
var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
if err := s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked); err != nil {
jsonErr(w, http.StatusNotFound, "user not found")
return
}
newVal := 1
if blocked != 0 {
newVal = 0
}
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id); err != nil {
jsonInternalError(w, err)
return
}
if newVal != 0 {
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
return
}
if strings.HasSuffix(path, "/reset-password") && r.Method == "POST" {
if !s.allowRate(w, r, "admin-reset", "") {
return
}
id := strings.TrimSuffix(path, "/reset-password")
id = strings.TrimSuffix(id, "/")
b := make([]byte, 12)
rand.Read(b)
newPass := hex.EncodeToString(b)
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
hash, err := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
if err != nil {
jsonInternalError(w, err)
return
}
_, err = s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
if err != nil {
jsonInternalError(w, err)
return
@ -450,8 +521,7 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&editReq); err != nil {
jsonErr(w, 400, "bad json")
if !decodeJSONBody(w, r, &editReq, s.cfg.Limits.MaxJSONBody) {
return
}
if editReq.Username == "" || editReq.Email == "" {
@ -468,20 +538,34 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
}
if r.Method == "DELETE" {
id := strings.TrimSuffix(path, "/")
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
var deviceIDs []string
for rows.Next() {
var did string
rows.Scan(&did)
deviceIDs = append(deviceIDs, did)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
rows.Close()
for _, did := range deviceIDs {
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
defer tx.Rollback()
for _, statement := range []string{
"DELETE FROM server_sessions WHERE subject_id=? AND scope='user'",
"DELETE FROM server_email_tokens WHERE user_id=?",
"DELETE FROM server_blob_refs WHERE user_id=?",
"DELETE FROM server_idempotency_keys WHERE user_id=?",
"DELETE FROM server_tombstones WHERE user_id=?",
"DELETE FROM server_revisions WHERE op_id IN (SELECT op_id FROM server_ops WHERE user_id=?)",
"DELETE FROM server_ops WHERE user_id=?",
"DELETE FROM server_user_devices WHERE user_id=?",
"DELETE FROM server_devices WHERE user_id=?",
"DELETE FROM server_audit_log WHERE user_id=?",
"DELETE FROM server_users WHERE id=?",
} {
if _, err := tx.Exec(statement, id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
jsonOK(w, map[string]interface{}{"status": "deleted"})
return
}
@ -496,8 +580,15 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(adminCreateUserHTML(locale)))
csrf := ""
if cookie, err := r.Cookie("csrf_token"); err == nil {
csrf = cookie.Value
}
w.Write([]byte(adminCreateUserHTML(locale, csrf)))
case "POST":
if !s.requireAdminMutation(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", 400)
return
@ -551,11 +642,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPICreateUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if !s.requireAdminMutation(w, r) {
return
}
var req struct {

View File

@ -2,12 +2,11 @@ package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"io"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -24,11 +23,24 @@ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]interface{}{
"status": "ok",
"version": "verstak-server/v1",
"time": time.Now().UTC().Format(time.RFC3339),
})
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
health := s.healthStatus(r.Context())
if health.Status != "ok" {
jsonOKStatus(w, http.StatusServiceUnavailable, health)
return
}
jsonOK(w, health)
}
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "server_time": time.Now().UTC().Format(time.RFC3339)})
}
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
@ -36,15 +48,7 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 405, "POST required")
return
}
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
ip = ip[:idx]
}
if !s.pairLimit.allow(ip) {
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
jsonErr(w, 429, "too many attempts")
return
}
ip := s.clientIP(r)
var req struct {
Login string `json:"login"`
Password string `json:"password"`
@ -52,14 +56,16 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
ClientVersion string `json:"client_version"`
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Login == "" || req.Password == "" {
jsonErr(w, 400, "login and password required")
return
}
if !s.allowRate(w, r, "pair", req.Login) {
return
}
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
@ -72,12 +78,16 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
if req.DeviceName == "" {
req.DeviceName = "unknown"
}
if err := validatePairRequest(req.Login, req.DeviceName, req.ClientVersion, req.VaultID); err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found")
jsonErr(w, 401, "invalid credentials")
return
}
@ -102,20 +112,33 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
token, prefix, suffix := genDeviceToken()
tokenHash := sha256Hex(token)
now := time.Now().UTC().Format(time.RFC3339)
apiKey := make([]byte, 20)
rand.Read(apiKey)
_, err = s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, vault_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
_, err = tx.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, legacy_api_key, user_id, vault_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, "disabled:"+deviceID, tokenHash, prefix, suffix,
userID, req.VaultID, req.ClientVersion, ip, now, now)
if err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
s.pairLimit.reset(ip)
if _, err := tx.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
jsonOK(w, map[string]interface{}{
"user_id": userID,
@ -135,14 +158,16 @@ func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
if !s.allowRate(w, r, "auth-test", req.Username) {
return
}
var hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
@ -171,21 +196,16 @@ func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 405, "POST required")
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID string
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
if err != nil {
jsonErr(w, 401, "invalid token")
device, ok := s.authenticateDevice(w, r)
if !ok {
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
if err := s.revokeDevice(device.DeviceID, now); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", device.UserID, device.DeviceID, s.clientIP(r), "device revoked by user")
jsonOK(w, map[string]string{"status": "revoked"})
}
@ -194,32 +214,27 @@ func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request
jsonErr(w, 405, "POST required")
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var curUserID string
err := s.db.QueryRow("SELECT user_id FROM server_devices WHERE token_hash=?", hash).Scan(&curUserID)
if err != nil || curUserID == "" {
jsonErr(w, 401, "invalid token")
device, ok := s.authenticateDevice(w, r)
if !ok || device.UserID == "" {
return
}
curUserID := device.UserID
var req struct {
DeviceID string `json:"device_id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.DeviceID == "" || req.Password == "" {
jsonErr(w, 400, "device_id and password required")
return
}
if !s.allowRate(w, r, "auth-test", curUserID) {
return
}
var pwHash string
err = s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", curUserID).Scan(&pwHash)
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", curUserID).Scan(&pwHash)
if err != nil {
jsonErr(w, 403, "access denied")
return
@ -239,21 +254,26 @@ func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
s.auditLog("device_revoked", curUserID, req.DeviceID, r.RemoteAddr, "device revoked via API")
if err := s.revokeDevice(req.DeviceID, now); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", curUserID, req.DeviceID, s.clientIP(r), "device revoked via API")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
device, ok := s.authenticateDevice(w, r)
if !ok {
return
}
hash := sha256Hex(tok)
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
FROM server_devices d WHERE d.token_hash=?`, hash).
FROM server_devices d WHERE d.id=?`, device.DeviceID).
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
if err != nil {
jsonErr(w, 401, "invalid token")
@ -284,8 +304,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Name == "" {
@ -296,6 +315,13 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "username and password required")
return
}
if !s.allowRate(w, r, "device-register", req.Username) {
return
}
if err := validatePairRequest(req.Username, req.Name, "", req.VaultID); err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
@ -325,61 +351,86 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "invalid credentials")
return
}
b := make([]byte, 20)
b := make([]byte, 12)
rand.Read(b)
apiKey := hex.EncodeToString(b)
deviceID := apiKey[:12]
deviceID := "dev_" + hex.EncodeToString(b)
token, prefix, suffix := genDeviceToken()
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, userID, req.VaultID, now, now,
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
_, err = tx.Exec(
"INSERT INTO server_devices (id, name, api_key, token_hash, token_prefix, token_suffix, legacy_api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)",
deviceID, req.Name, "disabled:"+deviceID, sha256Hex(token), prefix, suffix, userID, req.VaultID, now, now,
)
if err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
if _, err := tx.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"api_key": apiKey,
"device_id": deviceID,
"device_token": token,
})
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
var req syncPushRequest
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
var req struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
} `json:"ops"`
if code, message := s.validateSyncPush(req); code != "" {
status := http.StatusBadRequest
if code == "too_many_operations" || code == "payload_too_large" {
status = http.StatusRequestEntityTooLarge
}
jsonErrCode(w, status, code, message)
return
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if code, message, err := validateScopedBlobReferences(tx, scope, req.Ops); err != nil {
jsonInternalError(w, err)
return
} else if code != "" {
jsonErrCode(w, http.StatusBadRequest, code, message)
return
}
if req.IdempotencyKey != "" {
var cachedJSON string
err := s.db.QueryRow(`SELECT response_json FROM server_idempotency_keys
err := tx.QueryRow(`SELECT response_json FROM server_idempotency_keys
WHERE user_id=? AND vault_id=? AND idempotency_key=?`,
scope.UserID, scope.VaultID, req.IdempotencyKey).Scan(&cachedJSON)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(cachedJSON))
_, _ = w.Write([]byte(cachedJSON))
return
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
jsonInternalError(w, err)
return
}
}
@ -387,55 +438,82 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
var accepted []string
var conflicts []map[string]interface{}
for _, op := range req.Ops {
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
continue
}
if op.LastSeenServerSeq > 0 {
conflictRows, err := s.db.Query(`
conflictRows, err := tx.Query(`
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
WHERE user_id=? AND vault_id=? AND entity_type=? AND entity_id=? AND device_id!=?
AND server_sequence > ? AND op_type != 'delete'
ORDER BY server_sequence`, scope.UserID, scope.VaultID, op.EntityType, op.EntityID, scope.DeviceID, op.LastSeenServerSeq)
if err == nil {
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
var cSeq int
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
conflicts = append(conflicts, map[string]interface{}{
"op_id": cOpID,
"device_id": cDevID,
"op_type": cOpType,
"server_sequence": cSeq,
"entity_type": op.EntityType,
"entity_id": op.EntityID,
})
if err != nil {
jsonInternalError(w, err)
return
}
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
var cSeq int
if err := conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq); err != nil {
_ = conflictRows.Close()
jsonInternalError(w, err)
return
}
conflictRows.Close()
conflicts = append(conflicts, map[string]interface{}{
"op_id": cOpID,
"device_id": cDevID,
"op_type": cOpType,
"server_sequence": cSeq,
"entity_type": op.EntityType,
"entity_id": op.EntityID,
})
}
if err := conflictRows.Err(); err != nil {
_ = conflictRows.Close()
jsonInternalError(w, err)
return
}
if err := conflictRows.Close(); err != nil {
jsonInternalError(w, err)
return
}
}
res, err := s.db.Exec(
res, err := tx.Exec(
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, user_id, vault_id, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, scope.UserID, scope.VaultID, scope.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
)
if err != nil {
continue
jsonInternalError(w, err)
return
}
n, err := res.RowsAffected()
if err != nil {
jsonInternalError(w, err)
return
}
n, _ := res.RowsAffected()
if n == 0 {
continue
}
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, scope.DeviceID)
seqRes, err := tx.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, scope.DeviceID)
if err != nil {
continue
jsonInternalError(w, err)
return
}
seq, err := seqRes.LastInsertId()
if err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID); err != nil {
jsonInternalError(w, err)
return
}
seq, _ := seqRes.LastInsertId()
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
if op.OpType == "delete" {
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones
if _, err := tx.Exec(`INSERT OR REPLACE INTO server_tombstones
(user_id, vault_id, entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, op.EntityType, op.EntityID, op.OpID, now)
scope.UserID, scope.VaultID, op.EntityType, op.EntityID, op.OpID, now); err != nil {
jsonInternalError(w, err)
return
}
}
accepted = append(accepted, op.OpID)
}
@ -445,39 +523,54 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
"conflicts": conflicts,
}
if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil {
s.db.Exec(`INSERT OR IGNORE INTO server_idempotency_keys
(user_id, vault_id, idempotency_key, response_json, created_at) VALUES (?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, req.IdempotencyKey, string(respJSON), now)
respJSON, err := json.Marshal(resp)
if err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec(`INSERT INTO server_idempotency_keys
(user_id, vault_id, idempotency_key, response_json, created_at) VALUES (?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, req.IdempotencyKey, string(respJSON), now); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, resp)
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
var req syncPullRequest
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
var req struct {
SinceSequence int `json:"since_sequence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if req.SinceSequence < 0 || req.PageLimit < 0 {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "sequence and page_limit must be non-negative")
return
}
pageLimit := s.pullPageLimit(req.PageLimit)
var serverSeq int
s.db.QueryRow(`SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops
WHERE user_id=? AND vault_id=?`, scope.UserID, scope.VaultID).Scan(&serverSeq)
if err := s.db.QueryRow(`SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops
WHERE user_id=? AND vault_id=?`, scope.UserID, scope.VaultID).Scan(&serverSeq); err != nil {
jsonInternalError(w, err)
return
}
rows, err := s.db.Query(`
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
FROM server_ops
WHERE user_id=? AND vault_id=? AND server_sequence > ? AND server_sequence IS NOT NULL
ORDER BY server_sequence`, scope.UserID, scope.VaultID, req.SinceSequence)
ORDER BY server_sequence LIMIT ?`, scope.UserID, scope.VaultID, req.SinceSequence, pageLimit+1)
if err != nil {
jsonInternalError(w, err)
return
@ -493,81 +586,47 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
ops := []opDTO{}
ops := make([]opDTO, 0, pageLimit)
for rows.Next() {
var o opDTO
if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
continue
jsonInternalError(w, err)
return
}
ops = append(ops, o)
}
if err := rows.Err(); err != nil {
jsonInternalError(w, err)
return
}
hasMore := len(ops) > pageLimit
if hasMore {
ops = ops[:pageLimit]
}
pageLastSequence := req.SinceSequence
if len(ops) > 0 {
pageLastSequence = ops[len(ops)-1].ServerSequence
}
jsonOK(w, map[string]interface{}{
"server_sequence": serverSeq,
"ops": ops,
"server_sequence": serverSeq,
"page_last_sequence": pageLastSequence,
"has_more": hasMore,
"ops": ops,
})
}
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
switch r.Method {
case "POST":
if err := r.ParseMultipartForm(200 << 20); err != nil {
jsonErr(w, 400, "invalid multipart request")
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "file field required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
jsonErr(w, 500, "read error")
return
}
hash := sha256Hex(string(data))
blobDir := filepath.Join(s.blobsDir, hash[:2], hash[2:4])
if err := os.MkdirAll(blobDir, 0750); err != nil {
jsonErr(w, 500, "mkdir error")
return
}
blobPath := filepath.Join(blobDir, hash)
if err := os.WriteFile(blobPath, data, 0640); err != nil {
jsonErr(w, 500, "write error")
return
}
_ = header
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
hash, len(data), now)
jsonOK(w, map[string]interface{}{
"sha256": hash,
"size": len(data),
})
s.handleBlobUpload(w, r, scope)
case "GET":
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
if len(shaHex) != 64 {
jsonErr(w, 400, "invalid SHA-256")
return
}
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
jsonErr(w, 404, "blob not found")
return
}
data, err := os.ReadFile(blobPath)
if err != nil {
jsonErr(w, 500, "read error")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
w.Write(data)
s.handleBlobDownload(w, r, scope, shaHex)
default:
jsonErr(w, 405, "method not allowed")
methodNotAllowed(w, "GET", "POST")
}
}

View File

@ -3,7 +3,7 @@ package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"html"
"log"
"net/http"
"strings"
@ -13,8 +13,8 @@ import (
)
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
var req struct {
@ -22,14 +22,16 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
jsonErr(w, 400, "username, email and password required")
return
}
if !s.allowRate(w, r, "register", req.Email) {
return
}
if err := validatePassword(req.Password); err != "" {
jsonErr(w, 400, err)
return
@ -47,7 +49,13 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
_, err = tx.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
)
@ -59,29 +67,54 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
jsonInternalError(w, err)
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
log.Printf("register: confirmation token=%s for user %s", tokenStr, req.Username)
if _, err := issueEmailToken(tx, userID, "confirm", 48*time.Hour); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]string{"status": "confirmation_sent"})
}
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
if r.Method == http.MethodGet {
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "token required")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<form method="POST"><input type="hidden" name="token" value="` + html.EscapeString(tokenStr) + `"><button>Confirm email</button></form>`))
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodGet, http.MethodPost)
return
}
tokenStr := ""
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var req struct {
Token string `json:"token"`
}
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
tokenStr = req.Token
} else if err := r.ParseForm(); err == nil {
tokenStr = r.FormValue("token")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
return
}
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
jsonErr(w, 400, "token required")
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
tokenStr).Scan(&userID, &expiresAt)
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='confirm'",
emailTokenHash(tokenStr)).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
return
@ -91,29 +124,47 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "token expired")
return
}
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("DELETE FROM server_email_tokens WHERE token_hash=?", emailTokenHash(tokenStr)); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
log.Printf("confirm: user %s confirmed email", userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
jsonOK(w, map[string]string{"status": "confirmed"})
}
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
if !s.allowRate(w, r, "login", req.Username) {
return
}
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
@ -134,61 +185,68 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "invalid credentials")
return
}
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
tok := s.userTokens.Create(userID)
if _, err := s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID); err != nil {
jsonInternalError(w, err)
return
}
tok, _, err := s.createSession(sessionScopeUser, userID)
if err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
}
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Email == "" {
jsonErr(w, 400, "email required")
return
}
if !s.allowRate(w, r, "forgot", req.Email) {
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
if err != nil {
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
log.Printf("forgot: reset token=%s for user %s", tokenStr, userID)
if _, err := issueEmailToken(s.db, userID, "reset", time.Hour); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
var req struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Token == "" || req.NewPassword == "" {
jsonErr(w, 400, "token and new_password required")
return
}
if !s.allowRate(w, r, "reset", "") {
return
}
if err := validatePassword(req.NewPassword); err != "" {
jsonErr(w, 400, err)
return

View File

@ -16,17 +16,11 @@ import (
)
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
cookie, err := r.Cookie("user_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
userID, ok := s.userTokens.Check(cookie.Value)
session, ok := s.requireSession(w, r, sessionScopeUser)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
return userID, true
return session.SubjectID, true
}
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
@ -51,6 +45,9 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/register")))
return
}
if !s.allowRate(w, r, "register", email) {
return
}
if err := validatePassword(password); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
@ -84,12 +81,11 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
}
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
tokenStr, err := issueEmailToken(s.db, userID, "confirm", 48*time.Hour)
if err != nil {
jsonInternalError(w, err)
return
}
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
@ -103,15 +99,11 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
if err := s.smtpSend(email, t(locale, "server.emailConfirmSubject"), body); err != nil {
log.Printf("register web: failed to send confirm email: %v", err)
}
} else {
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
} else if s.cfg.DevelopmentTokenLogging {
log.Printf("development confirmation token for user %s: %s", username, tokenStr)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
regMsg := registrationOKHTML(locale)
if host == "" {
regMsg = registrationAutoHTML(locale)
}
w.Write([]byte(regMsg))
w.Write([]byte(registrationOKHTML(locale)))
default:
jsonErr(w, 405, "method not allowed")
}
@ -134,6 +126,9 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.needEmail"), "/forgot")))
return
}
if !s.allowRate(w, r, "forgot", email) {
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
if err != nil {
@ -141,13 +136,11 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(forgotSentHTML(locale)))
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
tokenStr, err := issueEmailToken(s.db, userID, "reset", time.Hour)
if err != nil {
jsonInternalError(w, err)
return
}
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
@ -159,8 +152,8 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
if err := s.smtpSend(email, t(locale, "server.emailResetSubject"), body); err != nil {
log.Printf("forgot web: failed to send reset email: %v", err)
}
} else {
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
} else if s.cfg.DevelopmentTokenLogging {
log.Printf("development reset token requested for %s: %s", email, tokenStr)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotSentHTML(locale)))
@ -179,8 +172,8 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
token).Scan(&userID, &expiresAt)
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='reset'",
emailTokenHash(token)).Scan(&userID, &expiresAt)
if err != nil {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
@ -206,6 +199,9 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/forgot")))
return
}
if !s.allowRate(w, r, "reset", "") {
return
}
if err := validatePassword(newPass); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+url.QueryEscape(token))))
@ -248,6 +244,9 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
}
username := r.FormValue("username")
password := r.FormValue("password")
if !s.allowRate(w, r, "login", username) {
return
}
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
@ -258,12 +257,12 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(errorPageHTML(locale, "401 Unauthorized", "401 Unauthorized", "/login")))
return
}
tok := s.userTokens.Create(userID)
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: tok, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
tok, csrf, err := s.createSession(sessionScopeUser, userID)
if err != nil {
jsonInternalError(w, err)
return
}
s.setSessionCookies(w, r, sessionScopeUser, tok, csrf)
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
@ -329,18 +328,36 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
}
}
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows)))
csrf := ""
if cookie, err := r.Cookie("csrf_token"); err == nil {
csrf = cookie.Value
}
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows, csrf)))
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: "", Path: "/",
HttpOnly: true, MaxAge: -1,
})
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if !s.requireUserMutation(w, r) {
return
}
if cookie, err := r.Cookie("user_session"); err == nil {
if err := s.deleteSession(cookie.Value); err != nil {
jsonInternalError(w, err)
return
}
}
s.clearSessionCookies(w, r, sessionScopeUser)
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
@ -374,3 +391,58 @@ func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
}
jsonOK(w, devices)
}
// handleUserWebDeviceAction is deliberately session/CSRF based. Browser UI
// never receives a desktop device bearer token.
func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
session, ok := s.requireSession(w, r, sessionScopeUser)
if !ok || !s.verifyCSRF(w, r, session) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/user/devices/")
if !strings.HasSuffix(path, "/revoke") {
jsonErr(w, http.StatusNotFound, "not found")
return
}
deviceID := strings.TrimSuffix(strings.TrimSuffix(path, "/revoke"), "/")
var req struct {
Password string `json:"password"`
}
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) {
if req.Password == "" {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
}
return
}
var hash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
jsonInternalError(w, err)
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, http.StatusForbidden, "wrong password")
return
}
var owner string
if err := s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", deviceID).Scan(&owner); err != nil {
jsonErr(w, http.StatusNotFound, "device not found")
return
}
if owner != session.SubjectID {
jsonErr(w, http.StatusForbidden, "device does not belong to you")
return
}
if err := s.revokeDevice(deviceID, time.Now().UTC().Format(time.RFC3339)); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
jsonOK(w, map[string]string{"status": "revoked"})
}

View File

@ -0,0 +1,645 @@
package server
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func newTestServer(t *testing.T) (*Server, error) {
t.Helper()
return newServerForTest(t, DefaultConfig())
}
func newServerForTest(t *testing.T, cfg *Config) (*Server, error) {
t.Helper()
dir := t.TempDir()
return NewServer(filepath.Join(dir, "server.db"), filepath.Join(dir, "data"), cfg)
}
func serveJSON(t *testing.T, s *Server, method, path, token string, body interface{}) (int, map[string]interface{}) {
t.Helper()
data, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
result := make(map[string]interface{})
if len(res.Body.Bytes()) > 0 {
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatalf("decode response: %v (%s)", err, res.Body.String())
}
}
return res.Code, result
}
func insertScopedSyncDevice(t *testing.T, s *Server, deviceID, userID, vaultID, token string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, user_id, vault_id, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, deviceID, deviceID, "legacy-"+deviceID, sha256Hex(token), userID, vaultID, now, now); err != nil {
t.Fatalf("insert scoped device: %v", err)
}
}
func uploadBlob(t *testing.T, s *Server, token string, data []byte) (int, map[string]interface{}) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "blob.bin")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/blobs/", &body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", writer.FormDataContentType())
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
result := map[string]interface{}{}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatalf("decode blob response: %v (%s)", err, res.Body.String())
}
return res.Code, result
}
func TestDefaultListenAddressIsLoopback(t *testing.T) {
cfg := DefaultConfig()
if got, want := cfg.ListenAddress(), "127.0.0.1:47732"; got != want {
t.Fatalf("default listen address = %q, want %q", got, want)
}
}
func TestHTTPServerUsesExplicitTimeouts(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
httpServer := s.HTTPServer("127.0.0.1:0")
if httpServer.ReadHeaderTimeout <= 0 || httpServer.ReadTimeout <= 0 || httpServer.WriteTimeout <= 0 || httpServer.IdleTimeout <= 0 {
t.Fatalf("HTTP timeouts must all be set: %#v", httpServer)
}
if httpServer.MaxHeaderBytes <= 0 {
t.Fatalf("MaxHeaderBytes must be set: %#v", httpServer)
}
}
func TestHTTPServerGracefulShutdown(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := s.HTTPServer(listener.Addr().String())
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
response, err := http.Get("http://" + listener.Addr().String())
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
t.Fatalf("shutdown: %v", err)
}
if err := <-done; err != nil && err != http.ErrServerClosed {
t.Fatalf("serve returned %v", err)
}
}
func TestClientIPIgnoresUntrustedForwardedHeaders(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
r := httptest.NewRequest(http.MethodPost, "/api/client/pair", nil)
r.RemoteAddr = "198.51.100.20:4040"
r.Header.Set("X-Forwarded-For", "203.0.113.8")
if got, want := s.clientIP(r), "198.51.100.20"; got != want {
t.Fatalf("client IP = %q, want %q", got, want)
}
}
func TestClientIPUsesTrustedProxyHeadersOnlyForTrustedPeer(t *testing.T) {
cfg := DefaultConfig()
cfg.TrustedProxies = []string{"127.0.0.1/32"}
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
r := httptest.NewRequest(http.MethodPost, "/api/client/pair", nil)
r.RemoteAddr = "127.0.0.1:4040"
r.Header.Set("X-Forwarded-For", "203.0.113.8, 127.0.0.1")
if got, want := s.clientIP(r), "203.0.113.8"; got != want {
t.Fatalf("client IP = %q, want %q", got, want)
}
}
func TestRateLimiterRecoversAfterWindow(t *testing.T) {
now := time.Now().UTC()
limiter := newRateLimiter(func() time.Time { return now })
policy := RatePolicy{Limit: 2, Window: time.Minute}
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
t.Fatal("first attempt unexpectedly limited")
}
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
t.Fatal("second attempt unexpectedly limited")
}
if allowed, retryAfter := limiter.Allow("198.51.100.1", policy); allowed || retryAfter <= 0 {
t.Fatalf("third attempt = allowed:%t retry:%s, want limited with retry", allowed, retryAfter)
}
now = now.Add(time.Minute + time.Second)
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
t.Fatal("attempt after window remained limited")
}
}
func TestRateLimiterMemoryIsBounded(t *testing.T) {
limiter := newRateLimiter(nil)
policy := RatePolicy{Limit: 1, Window: time.Hour}
for i := 0; i < maxRateLimitBuckets+100; i++ {
if allowed, _ := limiter.Allow(strconvItoa(i), policy); !allowed {
t.Fatalf("new bucket %d unexpectedly limited", i)
}
}
if got := len(limiter.buckets); got > maxRateLimitBuckets {
t.Fatalf("rate buckets = %d, want <= %d", got, maxRateLimitBuckets)
}
}
func TestSyncPushRejectsOversizedJSONBody(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxJSONBody = 64
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
overSizedJSON := append([]byte(`{"ops":[],"padding":"`), bytes.Repeat([]byte("x"), 65)...)
overSizedJSON = append(overSizedJSON, []byte(`"}`)...)
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", bytes.NewReader(overSizedJSON))
req.Header.Set("Authorization", "Bearer token-a")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d: %s", res.Code, http.StatusRequestEntityTooLarge, res.Body.String())
}
var body map[string]string
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["code"] != "request_too_large" {
t.Fatalf("error body = %#v, want stable request_too_large code", body)
}
}
func TestSyncPushRejectsOperationCountAboveLimit(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxPushOperations = 1
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
body := syncPushBody("device-a", "op-1", "")
body["ops"] = append(body["ops"].([]map[string]interface{}), map[string]interface{}{
"op_id": "op-2", "entity_type": "file", "entity_id": "Docs/two.txt", "op_type": "create",
"payload_json": `{"path":"Docs/two.txt","content":"two"}`, "created_at": "2026-07-10T00:00:00Z",
})
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", body)
if status != http.StatusRequestEntityTooLarge || response["code"] != "too_many_operations" {
t.Fatalf("status=%d body=%#v, want 413 too_many_operations", status, response)
}
}
func TestSyncPushRejectsTrailingJSONAndOversizedPayload(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxPayloadJSON = 32
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
data, err := json.Marshal(syncPushBody("device-a", "op-trailing", ""))
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", bytes.NewReader(append(data, []byte(` {}`)...)))
req.Header.Set("Authorization", "Bearer token-a")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest || !bytes.Contains(res.Body.Bytes(), []byte(`"trailing_json"`)) {
t.Fatalf("trailing JSON status=%d body=%s", res.Code, res.Body.String())
}
body := syncPushBody("device-a", "op-large-payload", "")
body["ops"].([]map[string]interface{})[0]["payload_json"] = `{"path":"Docs/large.txt","content":"this is intentionally longer than the configured payload bound"}`
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", body)
if status != http.StatusRequestEntityTooLarge || response["code"] != "payload_too_large" {
t.Fatalf("payload limit status=%d body=%#v", status, response)
}
}
func TestSyncPullPaginationHasNoGapsOrRepeats(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxPullPage = 2
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
for _, opID := range []string{"op-1", "op-2", "op-3", "op-4", "op-5"} {
if status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", opID, "")); status != http.StatusOK {
t.Fatalf("push %s status=%d body=%#v", opID, status, response)
}
}
cursor := 0
var sequences []int
for page := 0; page < 3; page++ {
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/pull", "token-a", map[string]int{"since_sequence": cursor, "page_limit": 2})
if status != http.StatusOK {
t.Fatalf("pull page %d status=%d body=%#v", page, status, response)
}
for _, raw := range response["ops"].([]interface{}) {
sequences = append(sequences, int(raw.(map[string]interface{})["server_sequence"].(float64)))
}
cursor = int(response["page_last_sequence"].(float64))
if !response["has_more"].(bool) {
break
}
}
if got, want := len(sequences), 5; got != want {
t.Fatalf("sequences=%v, want five ordered values", sequences)
}
for i, sequence := range sequences {
if sequence != i+1 {
t.Fatalf("sequences=%v, want [1 2 3 4 5]", sequences)
}
}
}
func TestBlobOwnershipPreventsCrossVaultDownload(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
insertScopedSyncDevice(t, s, "device-b", "user-b", "vault-b", "token-b")
status, uploaded := uploadBlob(t, s, "token-a", []byte("private blob"))
if status != http.StatusOK {
t.Fatalf("upload status=%d body=%#v", status, uploaded)
}
sha := uploaded["sha256"].(string)
request := httptest.NewRequest(http.MethodGet, "/api/v1/blobs/"+sha, nil)
request.Header.Set("Authorization", "Bearer token-b")
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("cross-vault download status=%d, want 404: %s", response.Code, response.Body.String())
}
}
func TestBlobLimitAndQuotaRejectWithoutResidualFile(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxBlobBytes = 8
cfg.Limits.MaxVaultBlobBytes = 8
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
tooLarge := []byte("012345678")
status, body := uploadBlob(t, s, "token-a", tooLarge)
if status != http.StatusRequestEntityTooLarge || body["code"] != "blob_too_large" {
t.Fatalf("file limit status=%d body=%#v", status, body)
}
sum := sha256.Sum256(tooLarge)
path := blobPath(s.blobsDir, hex.EncodeToString(sum[:]))
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("rejected oversized blob left physical file: %v", err)
}
status, body = uploadBlob(t, s, "token-a", []byte("12345678"))
if status != http.StatusOK {
t.Fatalf("first quota upload status=%d body=%#v", status, body)
}
quotaCandidate := []byte("abcdefgh")
status, body = uploadBlob(t, s, "token-a", quotaCandidate)
if status != http.StatusRequestEntityTooLarge || body["code"] != "quota_exceeded" {
t.Fatalf("quota status=%d body=%#v", status, body)
}
sum = sha256.Sum256(quotaCandidate)
if _, err := os.Stat(blobPath(s.blobsDir, hex.EncodeToString(sum[:]))); !os.IsNotExist(err) {
t.Fatalf("quota-rejected blob left physical file: %v", err)
}
}
func TestBlobUploadIsIdempotentWithinScope(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
data := []byte("same content")
_, first := uploadBlob(t, s, "token-a", data)
_, second := uploadBlob(t, s, "token-a", data)
if first["sha256"] != second["sha256"] || first["size"] != second["size"] {
t.Fatalf("idempotent uploads differ: first=%#v second=%#v", first, second)
}
var refs int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_blob_refs WHERE user_id=? AND vault_id=?", "user-a", "vault-a").Scan(&refs); err != nil {
t.Fatal(err)
}
if refs != 1 {
t.Fatalf("blob refs = %d, want one", refs)
}
}
func TestRevokedDeviceCannotUseBlobEndpoints(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
status, uploaded := uploadBlob(t, s, "token-a", []byte("before revoke"))
if status != http.StatusOK {
t.Fatalf("upload status=%d body=%#v", status, uploaded)
}
if err := s.revokeDevice("device-a", time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/blobs/"+uploaded["sha256"].(string), nil)
request.Header.Set("Authorization", "Bearer token-a")
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("revoked blob download status=%d, want 401", response.Code)
}
}
func TestAdminKeysNeverReturnPlaintextCredential(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "secret-device-token")
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/admin/api/keys", nil)
request.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK || bytes.Contains(response.Body.Bytes(), []byte("secret-device-token")) || bytes.Contains(response.Body.Bytes(), []byte("api_key")) {
t.Fatalf("admin keys leaked a credential: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHealthReportsDegradedDatabase(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if err := s.db.Close(); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || !bytes.Contains(response.Body.Bytes(), []byte(`"database_reachable":false`)) {
t.Fatalf("readiness after db close: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestRetentionDoesNotPruneOperations(t *testing.T) {
cfg := DefaultConfig()
cfg.Retention.IdempotencyHours = 1
cfg.Retention.AuditDays = 1
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
if status, body := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-retained", "")); status != http.StatusOK {
t.Fatalf("push status=%d body=%#v", status, body)
}
if err := s.CleanupRetention(time.Now().UTC().Add(48 * time.Hour)); err != nil {
t.Fatal(err)
}
var ops int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&ops); err != nil {
t.Fatal(err)
}
if ops != 1 {
t.Fatalf("retention removed sync operations: %d", ops)
}
}
func TestWebSessionSurvivesServerRestartAndLogoutInvalidatesIt(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "server.db")
dataDir := filepath.Join(dir, "data")
s, err := NewServer(dbPath, dataDir, DefaultConfig())
if err != nil {
t.Fatal(err)
}
token, csrf, err := s.createSession(sessionScopeUser, "user-a")
if err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
restarted, err := NewServer(dbPath, dataDir, DefaultConfig())
if err != nil {
t.Fatal(err)
}
defer restarted.Close()
restarted.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
req.Header.Set("X-CSRF-Token", csrf)
res := httptest.NewRecorder()
restarted.Handler().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("logout status=%d body=%s", res.Code, res.Body.String())
}
if _, ok := restarted.loadSession(token, sessionScopeUser); ok {
t.Fatal("logout did not invalidate server-side session")
}
}
func TestAdminMutationRejectsMissingCSRFToken(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
missing := httptest.NewRequest(http.MethodDelete, "/admin/api/keys/missing", nil)
missing.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
missingResult := httptest.NewRecorder()
s.Handler().ServeHTTP(missingResult, missing)
if missingResult.Code != http.StatusForbidden {
t.Fatalf("missing csrf status=%d, want 403", missingResult.Code)
}
valid := httptest.NewRequest(http.MethodDelete, "/admin/api/keys/missing", nil)
valid.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
valid.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
valid.Header.Set("X-CSRF-Token", csrf)
validResult := httptest.NewRecorder()
s.Handler().ServeHTTP(validResult, valid)
if validResult.Code != http.StatusOK {
t.Fatalf("valid csrf status=%d body=%s", validResult.Code, validResult.Body.String())
}
}
func TestAdminUserDeletionIsTransactionalAcrossOwnedRows(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
if status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-a", "")); status != http.StatusOK {
t.Fatalf("push status=%d body=%#v", status, response)
}
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodDelete, "/admin/api/users/user-a", nil)
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
request.Header.Set("X-CSRF-Token", csrf)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", response.Code, response.Body.String())
}
for table, where := range map[string]string{
"server_users": "id='user-a'",
"server_devices": "user_id='user-a'",
"server_ops": "user_id='user-a'",
"server_blob_refs": "user_id='user-a'",
} {
var count int
if err := s.db.QueryRow("SELECT COUNT(*) FROM " + table + " WHERE " + where).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("%s still has %d rows after user deletion", table, count)
}
}
}
func TestResetTokenIsHashedAndSingleUse(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
insertPairableUser(t, s, "user-a", "alice", "correct horse battery staple")
token, err := issueEmailToken(s.db, "user-a", "reset", time.Hour)
if err != nil {
t.Fatal(err)
}
var stored string
if err := s.db.QueryRow("SELECT token_hash FROM server_email_tokens WHERE user_id=?", "user-a").Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == token || stored != emailTokenHash(token) {
t.Fatalf("stored reset credential is not a token hash: %q", stored)
}
if _, err := s.resetPasswordWithToken(token, "a new secure password"); err != nil {
t.Fatalf("first reset: %v", err)
}
if _, err := s.resetPasswordWithToken(token, "another secure password"); err != errResetTokenInvalid {
t.Fatalf("second reset err=%v, want invalid token", err)
}
}

116
internal/server/health.go Normal file
View File

@ -0,0 +1,116 @@
package server
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
)
type HealthStatus struct {
Status string `json:"status"`
Version string `json:"version"`
BuildCommit string `json:"build_commit"`
UptimeSeconds int64 `json:"uptime_seconds"`
DatabaseReachable bool `json:"database_reachable"`
BlobStorageWritable bool `json:"blob_storage_writable"`
SchemaVersion int `json:"schema_version"`
ServerTime string `json:"server_time"`
}
func (s *Server) healthStatus(ctx context.Context) HealthStatus {
health := HealthStatus{
Status: "ok",
Version: Version,
BuildCommit: BuildCommit,
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
ServerTime: time.Now().UTC().Format(time.RFC3339),
}
if s.db == nil || s.db.PingContext(ctx) != nil {
health.DatabaseReachable = false
health.Status = "degraded"
} else {
health.DatabaseReachable = true
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&health.SchemaVersion); err != nil {
health.DatabaseReachable = false
health.Status = "degraded"
}
}
health.BlobStorageWritable = s.blobStorageWritable()
if !health.BlobStorageWritable {
health.Status = "degraded"
}
return health
}
func (s *Server) blobStorageWritable() bool {
if s.blobsDir == "" {
return false
}
probe, err := os.CreateTemp(s.blobsDir, ".health-*")
if err != nil {
return false
}
name := probe.Name()
if err := probe.Close(); err != nil {
_ = os.Remove(name)
return false
}
return os.Remove(name) == nil
}
// ServerStats is intentionally independent from the web UI so a future
// admin panel can expose operational data without coupling to templates.
type ServerStats struct {
Users int `json:"users"`
ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"`
Operations int `json:"operations"`
DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"`
}
func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
var stats ServerStats
queries := []struct {
query string
target *int
}{
{"SELECT COUNT(*) FROM server_users", &stats.Users},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices},
{"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults},
{"SELECT COUNT(*) FROM server_ops", &stats.Operations},
}
for _, query := range queries {
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
return ServerStats{}, err
}
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
return ServerStats{}, err
}
if info, err := os.Stat(s.dbPath); err == nil {
stats.DatabaseBytes = info.Size()
}
if err := filepath.WalkDir(s.blobsDir, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type().IsRegular() {
info, err := entry.Info()
if err != nil {
return err
}
stats.BlobBytes += info.Size()
}
return nil
}); err != nil && !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("blob storage stats: %w", err)
}
return stats, nil
}

View File

@ -4,8 +4,12 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
)
func jsonOK(w http.ResponseWriter, v interface{}) {
@ -13,10 +17,43 @@ func jsonOK(w http.ResponseWriter, v interface{}) {
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
func jsonOKStatus(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonErrCode(w, code, defaultErrorCode(code), msg)
}
// jsonErrCode preserves the legacy human-readable error field while giving
// Desktop a stable machine-readable code for localized messages.
func jsonErrCode(w http.ResponseWriter, status int, machineCode, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg, "code": machineCode})
}
func defaultErrorCode(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusUnauthorized:
return "unauthorized"
case http.StatusForbidden:
return "forbidden"
case http.StatusNotFound:
return "not_found"
case http.StatusMethodNotAllowed:
return "method_not_allowed"
case http.StatusRequestEntityTooLarge:
return "request_too_large"
case http.StatusTooManyRequests:
return "rate_limited"
default:
return "internal_error"
}
}
func jsonInternalError(w http.ResponseWriter, err error) {
@ -24,6 +61,52 @@ func jsonInternalError(w http.ResponseWriter, err error) {
jsonErr(w, http.StatusInternalServerError, "internal error")
}
func methodNotAllowed(w http.ResponseWriter, allowed ...string) {
w.Header().Set("Allow", strings.Join(allowed, ", "))
jsonErrCode(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
// decodeJSONBody enforces a hard byte limit, rejects a second JSON value, and
// keeps all request handlers on the same error contract.
func decodeJSONBody(w http.ResponseWriter, r *http.Request, destination interface{}, limit int64) bool {
if limit <= 0 {
limit = 1
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(destination); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
return false
}
jsonErrCode(w, http.StatusBadRequest, "invalid_json", "invalid JSON request")
return false
}
var trailing interface{}
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
jsonErrCode(w, http.StatusBadRequest, "trailing_json", "request must contain one JSON value")
} else {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_json", "invalid JSON request")
}
}
return false
}
return true
}
func validateStringLength(name, value string, max int) error {
if len(value) > max {
return fmt.Errorf("%s is too long", name)
}
return nil
}
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])

View File

@ -56,7 +56,7 @@ func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (aut
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
if err != nil {
err = s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
FROM server_devices WHERE api_key=?`, key).
FROM server_devices WHERE api_key=? AND legacy_api_key=1`, key).
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
}
if err != nil {
@ -75,23 +75,25 @@ func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (aut
}
if device.UserID != "" {
var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked)
if err := s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked); err != nil {
jsonInternalError(w, err)
return authenticatedDevice{}, false
}
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return authenticatedDevice{}, false
}
}
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), device.DeviceID)
if _, err := s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), device.DeviceID); err != nil {
jsonInternalError(w, err)
return authenticatedDevice{}, false
}
return device, true
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie("session")
if err != nil || !s.tokens.Check(cookie.Value) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
return true
_, ok := s.requireSession(w, r, sessionScopeAdmin)
return ok
}
type PasswordError string

View File

@ -22,7 +22,7 @@ func (s *Server) resetPasswordWithToken(token, newPassword string) (string, erro
var userID, expiresAt string
err = tx.QueryRow(`SELECT user_id, expires_at FROM server_email_tokens
WHERE token=? AND purpose='reset'`, token).Scan(&userID, &expiresAt)
WHERE token_hash=? AND purpose='reset'`, emailTokenHash(token)).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return "", errResetTokenInvalid
}
@ -35,7 +35,7 @@ func (s *Server) resetPasswordWithToken(token, newPassword string) (string, erro
}
deleted, err := tx.Exec(`DELETE FROM server_email_tokens
WHERE token=? AND purpose='reset' AND expires_at=?`, token, expiresAt)
WHERE token_hash=? AND purpose='reset' AND expires_at=?`, emailTokenHash(token), expiresAt)
if err != nil {
return "", err
}
@ -62,6 +62,9 @@ func (s *Server) resetPasswordWithToken(token, newPassword string) (string, erro
if updatedCount != 1 {
return "", errResetTokenInvalid
}
if _, err := tx.Exec("DELETE FROM server_email_tokens WHERE user_id=? AND purpose='reset'", userID); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}

34
internal/server/proxy.go Normal file
View File

@ -0,0 +1,34 @@
package server
import (
"net"
"net/http"
"net/netip"
"strings"
)
// clientIPFromPeer accepts forwarding headers only when the TCP peer is a
// configured trusted proxy. The first X-Forwarded-For address is the original
// client under the documented nginx/Caddy single-proxy configuration.
func (s *Server) clientIPFromPeer(peer, forwardedFor string) string {
peerAddr, err := netip.ParseAddr(strings.TrimSpace(peer))
if err != nil || s == nil || s.cfg == nil || !s.cfg.isTrustedProxy(peerAddr) {
return peer
}
for _, value := range strings.Split(forwardedFor, ",") {
candidate := strings.TrimSpace(value)
if addr, err := netip.ParseAddr(candidate); err == nil {
return addr.String()
}
}
return peerAddr.String()
}
func (s *Server) remotePeerIsTrusted(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(strings.Trim(host, "[]"))
return err == nil && s != nil && s.cfg != nil && s.cfg.isTrustedProxy(addr)
}

72
internal/server/rate.go Normal file
View File

@ -0,0 +1,72 @@
package server
import (
"math"
"net/http"
"strings"
"time"
)
var ratePolicies = map[string]RatePolicy{
"pair": {Limit: 5, Window: 15 * time.Minute},
"device-register": {Limit: 5, Window: 15 * time.Minute},
"auth-test": {Limit: 10, Window: 10 * time.Minute},
"login": {Limit: 10, Window: 10 * time.Minute},
"register": {Limit: 5, Window: time.Hour},
"forgot": {Limit: 5, Window: time.Hour},
"reset": {Limit: 8, Window: time.Hour},
"admin-reset": {Limit: 8, Window: time.Hour},
}
// allowRate applies an IP limit and, where a login/account is supplied, an
// additional bounded account bucket. It never logs submitted credentials.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
policy, ok := ratePolicies[action]
if !ok {
return true
}
ip := s.clientIP(r)
s.limiter.Cleanup(2 * policy.Window)
keys := []string{action + ":ip:" + ip}
account = strings.ToLower(strings.TrimSpace(account))
if account != "" {
// Keep attacker-controlled account strings out of the in-memory key and
// audit path while preserving an independent per-account bucket.
keys = append(keys, action+":account:"+sha256Hex(account))
}
for _, key := range keys {
if allowed, retryAfter := s.limiter.Allow(key, policy); !allowed {
seconds := int(math.Ceil(retryAfter.Seconds()))
if seconds < 1 {
seconds = 1
}
w.Header().Set("Retry-After", strconvItoa(seconds))
s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action)
jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false
}
}
return true
}
func strconvItoa(value int) string {
if value == 0 {
return "0"
}
negative := value < 0
if negative {
value = -value
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
if negative {
index--
digits[index] = '-'
}
return string(digits[index:])
}

View File

@ -0,0 +1,81 @@
package server
import (
"sync"
"time"
)
// RatePolicy is a fixed-window policy. Buckets expire naturally and cleanup
// prevents unauthenticated traffic from consuming unbounded memory.
type RatePolicy struct {
Limit int
Window time.Duration
}
type rateBucket struct {
Count int
WindowStart time.Time
LastSeen time.Time
}
type rateLimiter struct {
mu sync.Mutex
buckets map[string]rateBucket
now func() time.Time
}
const maxRateLimitBuckets = 10000
func newRateLimiter(now func() time.Time) *rateLimiter {
if now == nil {
now = time.Now
}
return &rateLimiter{buckets: make(map[string]rateBucket), now: now}
}
// Allow consumes one attempt and reports the remaining wait when limited.
func (l *rateLimiter) Allow(key string, policy RatePolicy) (bool, time.Duration) {
if policy.Limit <= 0 || policy.Window <= 0 {
return true, 0
}
now := l.now().UTC()
l.mu.Lock()
defer l.mu.Unlock()
bucket := l.buckets[key]
if bucket.WindowStart.IsZero() && len(l.buckets) >= maxRateLimitBuckets {
// An attacker can vary IP/account keys faster than age cleanup runs.
// Evict the least recently seen bucket to keep memory strictly bounded.
var oldestKey string
var oldest time.Time
for candidate, existing := range l.buckets {
if oldestKey == "" || existing.LastSeen.Before(oldest) {
oldestKey, oldest = candidate, existing.LastSeen
}
}
if oldestKey != "" {
delete(l.buckets, oldestKey)
}
}
if bucket.WindowStart.IsZero() || !now.Before(bucket.WindowStart.Add(policy.Window)) {
bucket = rateBucket{WindowStart: now}
}
bucket.LastSeen = now
if bucket.Count >= policy.Limit {
l.buckets[key] = bucket
return false, bucket.WindowStart.Add(policy.Window).Sub(now)
}
bucket.Count++
l.buckets[key] = bucket
return true, 0
}
func (l *rateLimiter) Cleanup(maxAge time.Duration) {
now := l.now().UTC()
l.mu.Lock()
defer l.mu.Unlock()
for key, bucket := range l.buckets {
if now.Sub(bucket.LastSeen) > maxAge {
delete(l.buckets, key)
}
}
}

View File

@ -0,0 +1,45 @@
package server
import (
"os"
"path/filepath"
"strings"
"time"
)
// CleanupRetention only removes independently expiring data. It intentionally
// never prunes server_ops or content-addressed blobs: the operation log remains
// required for a newly paired device until a future checkpoint protocol exists.
func (s *Server) CleanupRetention(now time.Time) error {
if err := s.cleanupExpiredSessions(); err != nil {
return err
}
if _, err := s.db.Exec("DELETE FROM server_email_tokens WHERE expires_at <= ?", now.UTC().Format(time.RFC3339)); err != nil {
return err
}
if _, err := s.db.Exec("DELETE FROM server_idempotency_keys WHERE created_at < ?", now.Add(-time.Duration(s.cfg.Retention.IdempotencyHours)*time.Hour).UTC().Format(time.RFC3339)); err != nil {
return err
}
if _, err := s.db.Exec("DELETE FROM server_audit_log WHERE created_at < ?", now.AddDate(0, 0, -s.cfg.Retention.AuditDays).UTC().Format(time.RFC3339)); err != nil {
return err
}
entries, err := os.ReadDir(s.blobsDir)
if err != nil && !os.IsNotExist(err) {
return err
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), ".upload-") {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
if info.ModTime().Before(now.Add(-time.Duration(s.cfg.Retention.TempUploadHours) * time.Hour)) {
if err := os.Remove(filepath.Join(s.blobsDir, entry.Name())); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}

View File

@ -2,6 +2,8 @@ package server
func (s *Server) routes() {
s.mux.HandleFunc("/api/v1/health", s.handleHealth)
s.mux.HandleFunc("/livez", s.handleLiveness)
s.mux.HandleFunc("/readyz", s.handleHealth)
s.mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
s.mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
s.mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
@ -23,6 +25,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/reset", s.handleUserWebReset)
s.mux.HandleFunc("/logout", s.handleUserWebLogout)
s.mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
s.mux.HandleFunc("/api/v1/user/devices/", s.handleUserWebDeviceAction)
s.mux.HandleFunc("/admin/login", s.handleAdminLogin)
s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
s.mux.HandleFunc("/admin/users", s.handleAdminUsers)

View File

@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS server_devices (
token_hash TEXT,
token_prefix TEXT,
token_suffix TEXT,
legacy_api_key INTEGER NOT NULL DEFAULT 0,
user_id TEXT,
vault_id TEXT,
client_version TEXT,
@ -76,13 +77,23 @@ CREATE TABLE IF NOT EXISTS server_idempotency_keys (
);
CREATE TABLE IF NOT EXISTS server_email_tokens (
token TEXT PRIMARY KEY,
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_sessions (
token_hash TEXT PRIMARY KEY,
csrf_hash TEXT NOT NULL,
scope TEXT NOT NULL,
subject_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
op_id TEXT NOT NULL,
@ -95,6 +106,16 @@ CREATE TABLE IF NOT EXISTS server_blobs (
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_blob_refs (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL,
last_accessed TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, sha256)
);
CREATE TABLE IF NOT EXISTS server_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
@ -114,6 +135,10 @@ 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);
CREATE INDEX IF NOT EXISTS idx_server_blob_refs_scope ON server_blob_refs(user_id, vault_id, sha256);
CREATE INDEX IF NOT EXISTS idx_server_blob_refs_user ON server_blob_refs(user_id, sha256);
CREATE INDEX IF NOT EXISTS idx_server_sessions_expiry ON server_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_server_sessions_subject ON server_sessions(scope, subject_id);
CREATE TABLE IF NOT EXISTS server_smtp_config (
key TEXT PRIMARY KEY,
@ -125,6 +150,8 @@ type sqliteColumn struct {
primaryKeyOrder int
}
const schemaVersion = 2
func migrateServerSchema(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
@ -150,6 +177,12 @@ func migrateServerSchema(db *sql.DB) error {
if err := backfillDeviceOwners(tx); err != nil {
return err
}
if err := migrateLegacyDeviceCredentials(tx); err != nil {
return err
}
if err := migrateEmailTokenHashes(tx); err != nil {
return err
}
if err := backfillOperationScope(tx); err != nil {
return err
}
@ -167,10 +200,67 @@ func migrateServerSchema(db *sql.DB) error {
ON server_devices(user_id, vault_id)`); err != nil {
return err
}
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
return err
}
return tx.Commit()
}
func migrateLegacyDeviceCredentials(tx *sql.Tx) error {
if err := ensureSQLiteColumn(tx, "server_devices", "legacy_api_key", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// Existing rows predate the hash-only enrollment path. Their API keys keep
// working only when explicitly marked legacy; devices enrolled by this
// version store a disabled placeholder and can never authenticate with it.
_, err := tx.Exec(`UPDATE server_devices SET legacy_api_key=1
WHERE legacy_api_key=0 AND api_key NOT LIKE 'disabled:%'`)
return err
}
func migrateEmailTokenHashes(tx *sql.Tx) error {
columns, err := sqliteTableColumns(tx, "server_email_tokens")
if err != nil {
return err
}
if _, ok := columns["token_hash"]; ok {
return nil
}
if _, err := tx.Exec("ALTER TABLE server_email_tokens RENAME TO server_email_tokens_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`CREATE TABLE server_email_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`); err != nil {
return err
}
rows, err := tx.Query(`SELECT token, user_id, purpose, expires_at, created_at FROM server_email_tokens_legacy`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var token, userID, purpose, expiresAt, createdAt string
if err := rows.Scan(&token, &userID, &purpose, &expiresAt, &createdAt); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO server_email_tokens (token_hash, user_id, purpose, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`, sha256Hex(token), userID, purpose, expiresAt, createdAt); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = tx.Exec("DROP TABLE server_email_tokens_legacy")
return err
}
func ensureSQLiteColumn(tx *sql.Tx, table, column, definition string) error {
columns, err := sqliteTableColumns(tx, table)
if err != nil {

View File

@ -1,133 +1,62 @@
package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net"
"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
db *sql.DB
dbPath string
cfg *Config
blobsDir string
mux *http.ServeMux
limiter *rateLimiter
startedAt time.Time
}
// Version and BuildCommit are assigned through -ldflags during release builds.
var (
Version = "dev"
BuildCommit = "unknown"
)
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) {
if cfg == nil {
cfg = DefaultConfig()
}
if err := cfg.normalize(); err != nil {
return nil, fmt.Errorf("config: %w", err)
}
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 _, pragma := range []string{
"PRAGMA foreign_keys = ON",
"PRAGMA busy_timeout = 5000",
"PRAGMA journal_mode = WAL",
"PRAGMA synchronous = NORMAL",
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
return nil, fmt.Errorf("sqlite %s: %w", pragma, err)
}
}
for _, stmt := range strings.Split(serverSchema, ";") {
stmt = strings.TrimSpace(stmt)
@ -151,12 +80,12 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
}
s := &Server{
db: db,
cfg: cfg,
tokens: newTokenStore(),
userTokens: newUserTokenStore(),
blobsDir: blobsDir,
pairLimit: &pairRateLimit{},
db: db,
dbPath: dbPath,
cfg: cfg,
blobsDir: blobsDir,
limiter: newRateLimiter(nil),
startedAt: time.Now().UTC(),
}
s.mux = http.NewServeMux()
return s, nil
@ -174,6 +103,36 @@ func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.mux)
// Handler is the only HTTP entrypoint. Additional request security middleware
// is composed here so tests and production use the same path.
func (s *Server) Handler() http.Handler {
return s.mux
}
// HTTPServer creates a conservatively configured server suitable for running
// behind nginx or Caddy. It intentionally does not enable TLS itself.
func (s *Server) HTTPServer(addr string) *http.Server {
return &http.Server{
Addr: addr,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 16 << 10,
}
}
// ListenAndServe exists for callers that do not need to manage lifecycle. The
// command entrypoint uses HTTPServer plus graceful shutdown instead.
func (s *Server) ListenAndServe(addr string) error {
return s.HTTPServer(addr).ListenAndServe()
}
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return s.clientIPFromPeer(host, r.Header.Get("X-Forwarded-For"))
}

View File

@ -111,7 +111,7 @@ func TestSyncPushPullStoresSequencedOps(t *testing.T) {
insertSyncUser(t, s, "user-a")
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO server_devices (id, name, api_key, legacy_api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
"device-a", "Device A", "api-key", "user-a", "vault-a", now, now,
); err != nil {
t.Fatalf("insert device: %v", err)
@ -185,7 +185,7 @@ func TestRevokedLegacyAPIKeyCannotPushOrPull(t *testing.T) {
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO server_devices (id, name, api_key, legacy_api_key, last_seen, revoked_at, created_at) VALUES (?, ?, ?, 1, ?, ?, ?)",
"device-revoked", "Revoked Device", "revoked-key", now, now, now,
); err != nil {
t.Fatalf("insert device: %v", err)
@ -389,8 +389,8 @@ func TestWebResetRejectsExpiredToken(t *testing.T) {
insertPairableUser(t, s, "user-a", "alice", oldPassword)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_email_tokens
(token, user_id, purpose, expires_at, created_at)
VALUES (?, ?, 'reset', ?, ?)`, "expired-reset-token", "user-a", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), now); err != nil {
(token_hash, user_id, purpose, expires_at, created_at)
VALUES (?, ?, 'reset', ?, ?)`, emailTokenHash("expired-reset-token"), "user-a", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), now); err != nil {
t.Fatalf("insert reset token: %v", err)
}
@ -438,18 +438,18 @@ func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
{
name: "user dashboard",
path: "/dashboard",
cookie: &http.Cookie{Name: "user_session", Value: s.userTokens.Create("user-a")},
cookie: testSessionCookie(t, s, sessionScopeUser, "user-a"),
containsDevID: true,
},
{
name: "admin users",
path: "/admin/users",
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
cookie: testSessionCookie(t, s, sessionScopeAdmin, "admin"),
},
{
name: "admin devices",
path: "/admin/devices",
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
cookie: testSessionCookie(t, s, sessionScopeAdmin, "admin"),
containsDevID: true,
},
}
@ -476,6 +476,19 @@ func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
}
}
func testSessionCookie(t *testing.T, s *Server, scope, subjectID string) *http.Cookie {
t.Helper()
token, _, err := s.createSession(scope, subjectID)
if err != nil {
t.Fatalf("create test session: %v", err)
}
name := "user_session"
if scope == sessionScopeAdmin {
name = "admin_session"
}
return &http.Cookie{Name: name, Value: token}
}
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "legacy.db")

173
internal/server/sessions.go Normal file
View File

@ -0,0 +1,173 @@
package server
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
)
const (
sessionScopeAdmin = "admin"
sessionScopeUser = "user"
sessionLifetime = 24 * time.Hour
)
type webSession struct {
Scope string
SubjectID string
CSRFHash string
ExpiresAt time.Time
}
func randomSecret(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// createSession stores only hashes. The plaintext session and CSRF values are
// returned once to be placed in cookies or an API login response.
func (s *Server) createSession(scope, subjectID string) (token, csrf string, err error) {
if scope != sessionScopeAdmin && scope != sessionScopeUser {
return "", "", fmt.Errorf("unknown session scope")
}
token, err = randomSecret(32)
if err != nil {
return "", "", err
}
csrf, err = randomSecret(32)
if err != nil {
return "", "", err
}
now := time.Now().UTC()
_, err = s.db.Exec(`INSERT INTO server_sessions
(token_hash, csrf_hash, scope, subject_id, expires_at, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sha256Hex(token), sha256Hex(csrf), scope, subjectID,
now.Add(sessionLifetime).Format(time.RFC3339), now.Format(time.RFC3339), now.Format(time.RFC3339))
if err != nil {
return "", "", err
}
return token, csrf, nil
}
func (s *Server) loadSession(token, scope string) (webSession, bool) {
if token == "" {
return webSession{}, false
}
var session webSession
var storedTokenHash string
var expiresAt string
err := s.db.QueryRow(`SELECT token_hash, csrf_hash, scope, subject_id, expires_at
FROM server_sessions WHERE token_hash=? AND scope=?`, sha256Hex(token), scope).
Scan(&storedTokenHash, &session.CSRFHash, &session.Scope, &session.SubjectID, &expiresAt)
if err != nil {
return webSession{}, false
}
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || !time.Now().UTC().Before(expires) {
_, _ = s.db.Exec("DELETE FROM server_sessions WHERE token_hash=?", sha256Hex(token))
return webSession{}, false
}
session.ExpiresAt = expires
// The database lookup is indexed by a fixed-size hash. Keep a constant-time
// comparison at the final token-hash boundary as well.
if subtle.ConstantTimeCompare([]byte(storedTokenHash), []byte(sha256Hex(token))) != 1 {
return webSession{}, false
}
_, _ = s.db.Exec("UPDATE server_sessions SET last_seen=? WHERE token_hash=?", time.Now().UTC().Format(time.RFC3339), sha256Hex(token))
return session, true
}
func (s *Server) deleteSession(token string) error {
if token == "" {
return nil
}
_, err := s.db.Exec("DELETE FROM server_sessions WHERE token_hash=?", sha256Hex(token))
return err
}
func (s *Server) deleteSessionsForSubject(scope, subjectID string) error {
_, err := s.db.Exec("DELETE FROM server_sessions WHERE scope=? AND subject_id=?", scope, subjectID)
return err
}
func (s *Server) setSessionCookies(w http.ResponseWriter, r *http.Request, scope, token, csrf string) {
name := "user_session"
path := "/"
if scope == sessionScopeAdmin {
name = "admin_session"
path = "/admin"
}
secure := s.requestIsHTTPS(r)
http.SetCookie(w, &http.Cookie{
Name: name, Value: token, Path: path, HttpOnly: true, Secure: secure,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionLifetime.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: "csrf_token", Value: csrf, Path: path, HttpOnly: false, Secure: secure,
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionLifetime.Seconds()),
})
}
func (s *Server) clearSessionCookies(w http.ResponseWriter, r *http.Request, scope string) {
name := "user_session"
path := "/"
if scope == sessionScopeAdmin {
name = "admin_session"
path = "/admin"
}
secure := s.requestIsHTTPS(r)
for _, cookieName := range []string{name, "csrf_token"} {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: path, HttpOnly: cookieName != "csrf_token", Secure: secure, MaxAge: -1})
}
}
func (s *Server) requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
if s == nil || s.cfg == nil {
return false
}
if !s.remotePeerIsTrusted(r) {
return false
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request, scope string) (webSession, bool) {
name := "user_session"
login := "/login"
if scope == sessionScopeAdmin {
name = "admin_session"
login = "/admin/login"
}
cookie, err := r.Cookie(name)
if err != nil {
http.Redirect(w, r, login, http.StatusFound)
return webSession{}, false
}
session, ok := s.loadSession(cookie.Value, scope)
if !ok {
http.Redirect(w, r, login, http.StatusFound)
return webSession{}, false
}
return session, true
}
func (s *Server) cleanupExpiredSessions() error {
_, err := s.db.Exec("DELETE FROM server_sessions WHERE expires_at <= ?", time.Now().UTC().Format(time.RFC3339))
return err
}
func isNoRows(err error) bool {
return err == sql.ErrNoRows
}

View File

@ -0,0 +1,95 @@
package server
import (
"encoding/json"
"fmt"
"strings"
)
const (
maxOpIDLength = 128
maxEntityTypeLength = 64
maxEntityIDLength = 4096
maxOpTypeLength = 64
maxIdempotencyKeyLength = 128
maxDeviceIDLength = 128
maxDeviceNameLength = 128
maxClientVersionLength = 256
maxVaultIDLength = 256
maxLoginLength = 320
)
type syncPushOperation struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
}
type syncPushRequest struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key"`
Ops []syncPushOperation `json:"ops"`
}
type syncPullRequest struct {
SinceSequence int `json:"since_sequence"`
PageLimit int `json:"page_limit"`
}
func (s *Server) validateSyncPush(req syncPushRequest) (code, message string) {
if len(req.Ops) > s.cfg.Limits.MaxPushOperations {
return "too_many_operations", "too many operations in one push"
}
for name, value := range map[string]struct {
value string
max int
}{
"device_id": {req.DeviceID, maxDeviceIDLength},
"idempotency_key": {req.IdempotencyKey, maxIdempotencyKeyLength},
} {
if err := validateStringLength(name, value.value, value.max); err != nil {
return "field_too_long", err.Error()
}
}
for _, op := range req.Ops {
for _, value := range []struct {
name string
value string
max int
}{
{"op_id", op.OpID, maxOpIDLength},
{"entity_type", op.EntityType, maxEntityTypeLength},
{"entity_id", op.EntityID, maxEntityIDLength},
{"op_type", op.OpType, maxOpTypeLength},
} {
if strings.TrimSpace(value.value) == "" {
return "invalid_operation", fmt.Sprintf("%s is required", value.name)
}
if err := validateStringLength(value.name, value.value, value.max); err != nil {
return "field_too_long", err.Error()
}
}
if len(op.PayloadJSON) > s.cfg.Limits.MaxPayloadJSON {
return "payload_too_large", "operation payload is too large"
}
if op.PayloadJSON != "" && !json.Valid([]byte(op.PayloadJSON)) {
return "invalid_payload", "operation payload_json must be valid JSON"
}
if op.ClientSequence < 0 || op.LastSeenServerSeq < 0 {
return "invalid_operation", "operation sequences must be non-negative"
}
}
return "", ""
}
func (s *Server) pullPageLimit(requested int) int {
if requested <= 0 || requested > s.cfg.Limits.MaxPullPage {
return s.cfg.Limits.MaxPullPage
}
return requested
}

View File

@ -454,7 +454,7 @@ function testSMTP(){
)
}
func userDashboardHTML(locale, username, deviceRows string) string {
func userDashboardHTML(locale, username, deviceRows, csrf string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@ -479,7 +479,7 @@ a{color:#6366f1}
</head><body>
<div class="top">
<h1>Verstak Sync</h1>
<span>%[1]s · <a href="/logout">%[2]s</a></span>
<span>%[1]s · <form action="/logout" method="POST" style="display:inline"><input type="hidden" name="csrf_token" value="%[14]s"><button type="submit" style="border:0;background:none;color:#6366f1;padding:0;cursor:pointer">%[2]s</button></form></span>
</div>
<h2>%[3]s</h2>
<table><tr><th>%[4]s</th><th>%[5]s</th><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th></tr>%[9]s</table>
@ -494,7 +494,7 @@ function revokeDevice(id){
if(!confirm('%[12]s'))return
var pw=prompt('%[13]s')
if(!pw)return
fetch('/api/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,password:pw})}).then(function(r){return r.json()}).then(function(d){
fetch('/api/v1/user/devices/'+encodeURIComponent(id)+'/revoke',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':'%[14]s'},body:JSON.stringify({password:pw})}).then(function(r){return r.json()}).then(function(d){
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
})
}
@ -513,10 +513,11 @@ function revokeDevice(id){
t(locale, "userDashboard.connectNewHint"),
t(locale, "userDashboard.revokeConfirm"),
t(locale, "userDashboard.revokePrompt"),
html.EscapeString(csrf),
)
}
func adminCreateUserHTML(locale string) string {
func adminCreateUserHTML(locale, csrf string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@ -534,6 +535,7 @@ button:hover{background:#4f46e5}
</style>
</head><body>
<form method="POST">
<input type="hidden" name="csrf_token" value="%[8]s">
<h1>%[2]s</h1>
<label>%[3]s</label>
<input type="text" name="username" autofocus required>
@ -552,6 +554,7 @@ button:hover{background:#4f46e5}
t(locale, "server.password"),
t(locale, "admin.createUserBtn"),
t(locale, "server.dashboard"),
html.EscapeString(csrf),
)
}

View File

@ -22,7 +22,9 @@ mkdir -p "$OUTPUT_DIR"
# Build
echo "→ Building server binary..."
cd "$REPO_ROOT"
go build -o "$BINARY" ./cmd/server
VERSION="${VERSION:-dev}"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
go build -ldflags "-X github.com/verstak/verstak-sync-server/internal/server.Version=$VERSION -X github.com/verstak/verstak-sync-server/internal/server.BuildCommit=$COMMIT" -o "$BINARY" ./cmd/server
echo "✅ Binary built: $BINARY"
ls -lh "$BINARY"

140
scripts/install.sh Normal file → Executable file
View File

@ -1,113 +1,89 @@
#!/bin/sh
#
# install.sh — установка Verstak Sync Server
#
# Использование:
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
#
# Флаги:
# --port Порт сервера (по умолчанию: 47732)
# --user Системный пользователь (по умолчанию: verstak)
# --admin-user Логин администратора (обязательный)
# --admin-pass Пароль администратора (обязательный)
# --bin Путь к бинарнику (по умолчанию: ./verstak-sync-server)
#
# Install a locally built Verstak Sync Server without exposing an admin
# password through argv or the installation log.
set -eu
umask 077
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
LISTEN="${VERSTAK_LISTEN:-127.0.0.1:47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
ADMIN_PASS_FILE=""
BIN="./verstak-sync-server"
# Parse flags
while [ $# -gt 0 ]; do
while [ "$#" -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--listen) LISTEN="$2"; shift 2 ;;
--port) LISTEN="127.0.0.1:$2"; shift 2 ;; # compatibility, still loopback
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--admin-pass-file) ADMIN_PASS_FILE="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
exit 1
if [ -z "$ADMIN_USER" ]; then
echo "Usage: $0 --admin-user USER [--admin-pass-file FILE] [--listen 127.0.0.1:47732]" >&2
exit 2
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
echo "This script must be run as root (sudo)." >&2
exit 1
fi
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first with ./scripts/build.sh" >&2
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
PASS_TMP="$(mktemp /tmp/verstak-admin-pass.XXXXXX)"
trap 'rm -f "$PASS_TMP"' EXIT HUP INT TERM
if [ -n "$ADMIN_PASS_FILE" ]; then
if [ ! -r "$ADMIN_PASS_FILE" ]; then
echo "Admin password file is not readable" >&2
exit 1
fi
cp "$ADMIN_PASS_FILE" "$PASS_TMP"
else
printf 'Initial admin password: ' >&2
stty -echo
IFS= read -r ADMIN_PASS
stty echo
printf '\n' >&2
printf '%s\n' "$ADMIN_PASS" > "$PASS_TMP"
unset ADMIN_PASS
fi
INSTALL_DIR="/opt/verstak-sync-server"
DATA_DIR="/var/lib/verstak-sync-server"
ENV_DIR="/etc/verstak-server"
install -d -m 0755 "$INSTALL_DIR"
install -m 0755 "$BIN" "$INSTALL_DIR/verstak-sync-server"
# 1. Create system user if not exists.
if ! id -u "$USER" >/dev/null 2>&1; then
echo "Creating user: $USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
fi
install -d -o "$USER" -g "$USER" -m 0750 "$DATA_DIR"
chown "$USER:$USER" "$PASS_TMP"
# 2. Install binary.
INSTALL_DIR="/opt/verstak-sync-server"
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/server/"
exit 1
fi
echo "Installing binary to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cp "$BIN" "$INSTALL_DIR/verstak-sync-server"
chmod 755 "$INSTALL_DIR/verstak-sync-server"
# 3. Create data directory.
DATA_DIR="/var/lib/verstak-sync-server"
echo "Creating $DATA_DIR"
mkdir -p "$DATA_DIR"
chown "$USER:$USER" "$DATA_DIR"
chmod 750 "$DATA_DIR"
# 4. Set up admin user (first run).
echo "Setting up admin user"
"$INSTALL_DIR/verstak-sync-server" \
--port "$PORT" \
--data "$DATA_DIR" \
--admin-user "$ADMIN_USER" \
--admin-pass "$ADMIN_PASS" &
# Initialize config as the service account. The process only receives the path
# to a 0600 temporary file, never password text in argv.
runuser -u "$USER" -- "$INSTALL_DIR/verstak-sync-server" \
--data "$DATA_DIR" --listen "$LISTEN" --admin-user "$ADMIN_USER" \
--admin-pass-file "$PASS_TMP" >/dev/null 2>&1 &
SERVER_PID=$!
sleep 2
sleep 1
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
# 5. Install systemd unit.
echo "Installing systemd unit"
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
cp "$(dirname "$0")/../verstak-server.service" "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
install -d -m 0750 "$ENV_DIR"
printf 'VERSTAK_LISTEN=%s\n' "$LISTEN" > "$ENV_DIR/env"
chmod 0640 "$ENV_DIR/env"
cp "$(dirname "$0")/../verstak-server.service" /etc/systemd/system/verstak-server.service
chmod 0644 /etc/systemd/system/verstak-server.service
# Set port in environment file.
mkdir -p /etc/verstak-server
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
# 6. Enable and start.
echo "Enabling and starting service"
systemctl daemon-reload
systemctl enable verstak-server
systemctl start verstak-server
systemctl restart verstak-server
echo ""
echo "=== Installation complete ==="
echo "Service: verstak-server"
echo "Port: $PORT"
echo "Admin: http://localhost:$PORT/admin/login"
echo ""
echo "Check status: systemctl status verstak-server"
echo "View logs: journalctl -u verstak-server -f"
echo "Installed verstak-server listening on $LISTEN."
echo "Admin: http://$LISTEN/admin/login"

View File

@ -15,9 +15,9 @@ if [[ "$(go env GOOS)" != "linux" || "$(go env GOARCH)" != "amd64" ]]; then
fi
echo "=== verstak sync server release $VERSION ==="
"$ROOT/scripts/build.sh"
VERSION="$VERSION" "$ROOT/scripts/build.sh"
RELEASE_ROOT="$ROOT/release"
RELEASE_ROOT="${RELEASE_ROOT:-$ROOT/release}"
STAGING="$RELEASE_ROOT/verstak-sync-server-$VERSION-linux-amd64"
ARCHIVE="$RELEASE_ROOT/verstak-sync-server-linux-amd64-$VERSION.tar.gz"
rm -rf "$STAGING" "$ARCHIVE"

View File

@ -7,16 +7,20 @@ Type=simple
User=verstak
Group=verstak
WorkingDirectory=/opt/verstak-sync-server
Environment=VERSTAK_PORT=47732
EnvironmentFile=-/etc/verstak-server/env
ExecStart=/opt/verstak-sync-server/verstak-sync-server --port ${VERSTAK_PORT} --data /var/lib/verstak-sync-server
ExecStart=/opt/verstak-sync-server/verstak-sync-server --data /var/lib/verstak-sync-server
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
ProtectKernelModules=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/verstak-sync-server
StateDirectory=verstak-sync-server
RuntimeDirectory=verstak-sync-server