From 6c26135cad2557febb0b017aa828e175a8effb8e Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 2 Mar 2026 21:21:21 +0000 Subject: [PATCH] Update --- .gitignore | 1 + README.md | 4 +- bin/fix-pg-remote.sh | 20 ++++ cmd/api/main.go | 10 +- docker-compose.yml | 5 + etc/pg-init-remote.sh | 5 + internal/app/service.go | 10 +- internal/http/handlers.go | 4 +- internal/http/logging.go | 95 +++++++++++++++++++ internal/store/interface.go | 1 + internal/store/memory.go | 4 + internal/store/migrate.go | 16 +++- .../0002_challenge_ip_user_logins.sql | 21 ++++ internal/store/postgres.go | 15 ++- internal/store/types.go | 8 ++ 15 files changed, 206 insertions(+), 13 deletions(-) create mode 100755 bin/fix-pg-remote.sh create mode 100755 etc/pg-init-remote.sh create mode 100644 internal/http/logging.go create mode 100644 internal/store/migrations/0002_challenge_ip_user_logins.sql diff --git a/.gitignore b/.gitignore index 15f5fca..4233cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ libs/geo-api-client/node_modules/ api +var/logs/ diff --git a/README.md b/README.md index ad8f5c6..4ea3dbf 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,11 @@ Or use `./bin/up.sh` which runs the key generation if needed. This starts: -- `db` (`postgis/postgis`) on `5432` +- `db` (`postgis/postgis`) on `5432` inside the container, exposed as **`7721`** on the host for remote access - `api` on `8122` — uses PostgreSQL via `DATABASE_URL` (migrations run on startup) +**Remote DB access** (e.g. `postgres://momswap:momswap@HOST_IP:7721/momswap?sslmode=disable`): The init script `etc/pg-init-remote.sh` configures `pg_hba.conf` for remote connections on fresh installs. If the DB was initialized before that was added, run once: `./bin/fix-pg-remote.sh` + Stop the service: ```bash diff --git a/bin/fix-pg-remote.sh b/bin/fix-pg-remote.sh new file mode 100755 index 0000000..c23c75b --- /dev/null +++ b/bin/fix-pg-remote.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Add pg_hba entries for remote connections to an already-initialized DB. +# Run this once if you started the DB before adding etc/pg-init-remote.sh. +set -e +CONTAINER="${1:-momswap-backend-db}" +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + echo "Container ${CONTAINER} is not running. Start it with: docker compose up -d db" + exit 1 +fi +docker exec "$CONTAINER" bash -c ' + PGDATA=/var/lib/postgresql/data + if grep -q "0.0.0.0/0" "$PGDATA/pg_hba.conf" 2>/dev/null; then + echo "Remote access already configured." + exit 0 + fi + echo "host all all 0.0.0.0/0 scram-sha-256" >> "$PGDATA/pg_hba.conf" + echo "host all all ::/0 scram-sha-256" >> "$PGDATA/pg_hba.conf" + psql -U "${POSTGRES_USER:-momswap}" -d "${POSTGRES_DB:-momswap}" -c "SELECT pg_reload_conf();" + echo "Remote access configured. Reloaded PostgreSQL." +' diff --git a/cmd/api/main.go b/cmd/api/main.go index 9aa1c20..1347300 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -49,8 +49,16 @@ func main() { service.BootstrapAdmin(adminPublicKey) api := httpapi.NewAPI(service) + h := api.Routes() + if logDir := getEnv("LOG_DIR", "var/logs"); logDir != "" { + if wrapped, err := httpapi.WithRequestLogging(logDir, h); err != nil { + log.Printf("request logging disabled: %v", err) + } else { + h = wrapped + } + } log.Printf("listening on %s", addr) - if err := http.ListenAndServe(addr, api.Routes()); err != nil { + if err := http.ListenAndServe(addr, h); err != nil { log.Fatalf("listen: %v", err) } } diff --git a/docker-compose.yml b/docker-compose.yml index bdd1d7b..8b5fe9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,15 @@ services: db: image: postgis/postgis:17-3.5 container_name: momswap-backend-db + ports: + - "7721:5432" environment: POSTGRES_DB: "${POSTGRES_DB:-momswap}" POSTGRES_USER: "${POSTGRES_USER:-momswap}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-momswap}" volumes: - ./var/posrgres:/var/lib/postgresql/data + - ./etc/pg-init-remote.sh:/docker-entrypoint-initdb.d/99-remote.sh:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-momswap} -d ${POSTGRES_DB:-momswap}"] interval: 10s @@ -31,6 +34,7 @@ services: DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" volumes: - ./etc:/app/etc:ro + - ./var/logs:/app/var/logs depends_on: db: condition: service_healthy @@ -54,6 +58,7 @@ services: DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" volumes: - ./etc:/src/etc:ro + - ./var/logs:/src/var/logs depends_on: db: condition: service_healthy diff --git a/etc/pg-init-remote.sh b/etc/pg-init-remote.sh new file mode 100755 index 0000000..cc83042 --- /dev/null +++ b/etc/pg-init-remote.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Allow remote connections to PostgreSQL (for clients connecting via host IP:7721) +set -e +echo "host all all 0.0.0.0/0 scram-sha-256" >> "$PGDATA/pg_hba.conf" +echo "host all all ::/0 scram-sha-256" >> "$PGDATA/pg_hba.conf" diff --git a/internal/app/service.go b/internal/app/service.go index 6fdce13..70e685e 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -81,7 +81,7 @@ func (s *Service) RegisterBySignature(publicKey, signature string) error { return nil } -func (s *Service) CreateChallenge(publicKey string) (string, error) { +func (s *Service) CreateChallenge(publicKey, clientIP string) (string, error) { if publicKey == "" { return "", fmt.Errorf("%w: missing public key", ErrBadRequest) } @@ -93,6 +93,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) { err = s.store.CreateChallenge(store.Challenge{ Nonce: nonce, PublicKey: publicKey, + IP: clientIP, ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL), Used: false, }) @@ -102,7 +103,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) { return nonce, nil } -func (s *Service) Login(publicKey, nonce, signature string) (string, error) { +func (s *Service) Login(publicKey, nonce, signature, clientIP string) (string, error) { ch, err := s.store.GetChallenge(nonce) if err != nil { return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized) @@ -132,6 +133,11 @@ func (s *Service) Login(publicKey, nonce, signature string) (string, error) { PublicKey: publicKey, ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL), }) + s.store.SaveUserLogin(store.UserLogin{ + PublicKey: publicKey, + IP: clientIP, + CreatedAt: time.Now().UTC(), + }) return token, nil } diff --git a/internal/http/handlers.go b/internal/http/handlers.go index a67bc1f..496651a 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -145,7 +145,7 @@ func (a *API) createChallenge(w http.ResponseWriter, r *http.Request) { writeErr(w, app.ErrBadRequest) return } - nonce, err := a.service.CreateChallenge(req.PublicKey) + nonce, err := a.service.CreateChallenge(req.PublicKey, clientIP(r)) if err != nil { writeErr(w, err) return @@ -163,7 +163,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { writeErr(w, app.ErrBadRequest) return } - token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature) + token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature, clientIP(r)) if err != nil { writeErr(w, err) return diff --git a/internal/http/logging.go b/internal/http/logging.go new file mode 100644 index 0000000..523f2a3 --- /dev/null +++ b/internal/http/logging.go @@ -0,0 +1,95 @@ +package httpapi + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// WithRequestLogging wraps h with a handler that logs each request to dir/access.log. +// Uses dir "var/logs" by default. Returns h unchanged if dir is empty. +func WithRequestLogging(dir string, h http.Handler) (http.Handler, error) { + if dir == "" { + return h, nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + f, err := os.OpenFile(filepath.Join(dir, "access.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + return &requestLogger{handler: h, file: f}, nil +} + +type requestLogger struct { + handler http.Handler + file *os.File + mu sync.Mutex +} + +func (l *requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ip := clientIP(r) + method := r.Method + path := r.URL.Path + userAgent := r.Header.Get("User-Agent") + if userAgent == "" { + userAgent = "-" + } + + wrapped := &responseRecorder{ResponseWriter: w, status: http.StatusOK} + l.handler.ServeHTTP(wrapped, r) + + elapsed := time.Since(start).Milliseconds() + line := formatLogLine(ip, method, path, wrapped.status, elapsed, strings.ReplaceAll(userAgent, "\"", "'")) + + l.mu.Lock() + _, _ = l.file.WriteString(line) + l.mu.Unlock() +} + +type responseRecorder struct { + http.ResponseWriter + status int +} + +func (r *responseRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func formatLogLine(ip, method, path string, status int, elapsedMs int64, userAgent string) string { + // Common log format: ip - - [timestamp] "method path protocol" status size "referer" "user-agent" elapsed_ms + t := time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700") + return fmt.Sprintf("%s - - [%s] \"%s %s\" %d %d \"-\" \"%s\" %dms\n", + ip, t, method, path, status, 0, userAgent, elapsedMs) +} + +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // First IP in the list is the original client + for i := 0; i < len(xff); i++ { + if xff[i] == ',' { + return xff[:i] + } + } + return xff + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + host, _ := splitHostPort(r.RemoteAddr) + return host +} + +func splitHostPort(addr string) (host, port string) { + if idx := strings.LastIndex(addr, ":"); idx >= 0 { + return addr[:idx], addr[idx+1:] + } + return addr, "" +} diff --git a/internal/store/interface.go b/internal/store/interface.go index 2955ea1..f34353f 100644 --- a/internal/store/interface.go +++ b/internal/store/interface.go @@ -8,6 +8,7 @@ type Store interface { CreateChallenge(ch Challenge) error GetChallenge(nonce string) (Challenge, error) MarkChallengeUsed(nonce string) error + SaveUserLogin(ul UserLogin) SaveSession(session Session) GetSession(token string) (Session, error) SaveInvitation(inv Invitation) error diff --git a/internal/store/memory.go b/internal/store/memory.go index c1cb474..b0d4624 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -230,3 +230,7 @@ func (s *MemoryStore) PruneExpired(now time.Time) { } } } + +func (s *MemoryStore) SaveUserLogin(ul UserLogin) { + // In-memory store: no-op for login history (persistence only in Postgres) +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index f2c136f..4886086 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -3,7 +3,9 @@ package store import ( "database/sql" "embed" + "io/fs" "log" + "sort" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -17,12 +19,20 @@ func Migrate(databaseURL string) error { return err } defer db.Close() - sql, err := migrationsFS.ReadFile("migrations/0001_init.sql") + + entries, err := fs.Glob(migrationsFS, "migrations/*.sql") if err != nil { return err } - if _, err := db.Exec(string(sql)); err != nil { - return err + sort.Strings(entries) + for _, name := range entries { + sql, err := migrationsFS.ReadFile(name) + if err != nil { + return err + } + if _, err := db.Exec(string(sql)); err != nil { + return err + } } log.Printf("migrations applied") return nil diff --git a/internal/store/migrations/0002_challenge_ip_user_logins.sql b/internal/store/migrations/0002_challenge_ip_user_logins.sql new file mode 100644 index 0000000..a03242d --- /dev/null +++ b/internal/store/migrations/0002_challenge_ip_user_logins.sql @@ -0,0 +1,21 @@ +-- Add ip column to challenges (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'challenges' AND column_name = 'ip' + ) THEN + ALTER TABLE challenges ADD COLUMN ip TEXT; + END IF; +END $$; + +-- User login history (ip, created_at per user) +CREATE TABLE IF NOT EXISTS user_logins ( + id SERIAL PRIMARY KEY, + public_key TEXT NOT NULL REFERENCES users(public_key) ON DELETE CASCADE, + ip TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_logins_public_key ON user_logins(public_key); +CREATE INDEX IF NOT EXISTS idx_user_logins_created_at ON user_logins(created_at); diff --git a/internal/store/postgres.go b/internal/store/postgres.go index d145377..5f9b763 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -54,8 +54,8 @@ func (s *PostgresStore) GetUser(publicKey string) (User, error) { func (s *PostgresStore) CreateChallenge(ch Challenge) error { _, err := s.db.Exec( - `INSERT INTO challenges (nonce, public_key, expires_at, used) VALUES ($1, $2, $3, $4)`, - ch.Nonce, ch.PublicKey, ch.ExpiresAt, ch.Used, + `INSERT INTO challenges (nonce, public_key, ip, expires_at, used) VALUES ($1, $2, $3, $4, $5)`, + ch.Nonce, ch.PublicKey, nullStr(ch.IP), ch.ExpiresAt, ch.Used, ) if err != nil { if isUniqueViolation(err) { @@ -69,9 +69,9 @@ func (s *PostgresStore) CreateChallenge(ch Challenge) error { func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) { var ch Challenge err := s.db.QueryRow( - `SELECT nonce, public_key, expires_at, used FROM challenges WHERE nonce = $1`, + `SELECT nonce, public_key, COALESCE(ip,''), expires_at, used FROM challenges WHERE nonce = $1`, nonce, - ).Scan(&ch.Nonce, &ch.PublicKey, &ch.ExpiresAt, &ch.Used) + ).Scan(&ch.Nonce, &ch.PublicKey, &ch.IP, &ch.ExpiresAt, &ch.Used) if errors.Is(err, sql.ErrNoRows) { return Challenge{}, ErrNotFound } @@ -282,6 +282,13 @@ func (s *PostgresStore) DeleteFeature(featureID string) error { return nil } +func (s *PostgresStore) SaveUserLogin(ul UserLogin) { + _, _ = s.db.Exec( + `INSERT INTO user_logins (public_key, ip, created_at) VALUES ($1, $2, $3)`, + ul.PublicKey, nullStr(ul.IP), ul.CreatedAt, + ) +} + func (s *PostgresStore) PruneExpired(now time.Time) { _, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now) _, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now) diff --git a/internal/store/types.go b/internal/store/types.go index 6431030..bbb4392 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -11,10 +11,18 @@ type User struct { type Challenge struct { Nonce string PublicKey string + IP string ExpiresAt time.Time Used bool } +// UserLogin records a successful login for a user (ip, created_at) +type UserLogin struct { + PublicKey string + IP string + CreatedAt time.Time +} + type Session struct { Token string PublicKey string