Files
lpra/internal/server/dbpg.go
T

252 lines
7.2 KiB
Go

package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// The panel keeps its data in SQLite by default, or in PostgreSQL when the
// admins choose so in Настройки. The choice lives in a small file beside the
// SQLite database (<db>.backend.json, readable by the server only) and
// takes effect on the next start; the first start on an empty PostgreSQL
// copies everything over from the SQLite file, which stays as it was.
type PGConf struct {
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
SSLMode string `json:"sslmode"`
}
type backendConf struct {
Type string `json:"type"` // sqlite or postgres
PG *PGConf `json:"pg,omitempty"`
}
func (c PGConf) DSN() string {
port := c.Port
if port == 0 {
port = 5432
}
ssl := c.SSLMode
if ssl == "" {
ssl = "prefer"
}
u := url.URL{Scheme: "postgres", User: url.UserPassword(c.User, c.Password), Host: c.Host + ":" + strconv.Itoa(port), Path: "/" + c.Name}
u.RawQuery = url.Values{"sslmode": {ssl}, "connect_timeout": {"10"}}.Encode()
return u.String()
}
func backendPath(dbPath string) string { return dbPath + ".backend.json" }
func readBackend(dbPath string) backendConf {
var c backendConf
if b, err := os.ReadFile(backendPath(dbPath)); err == nil {
_ = json.Unmarshal(b, &c)
}
if c.Type != "postgres" || c.PG == nil {
c = backendConf{Type: "sqlite"}
}
return c
}
func writeBackend(dbPath string, c backendConf) error {
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(backendPath(dbPath), b, 0o600)
}
// OpenStore opens the database the admins chose: SQLite at path, or the
// PostgreSQL named beside it.
func OpenStore(path string) (*DB, error) {
c := readBackend(path)
if c.Type == "postgres" {
return OpenPostgres(*c.PG, path)
}
return OpenDB(path)
}
// pingPG checks that PostgreSQL c answers.
func pingPG(c PGConf) error {
d, err := sql.Open("pgx", c.DSN())
if err != nil {
return err
}
defer d.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return d.PingContext(ctx)
}
// OpenPostgres opens PostgreSQL c, brings its tables up to date and, when it
// has no users yet, copies over the SQLite database at sqlitePath.
func OpenPostgres(c PGConf, sqlitePath string) (*DB, error) {
d, err := sql.Open("pgx", c.DSN())
if err != nil {
return nil, err
}
d.SetMaxOpenConns(16)
d.SetMaxIdleConns(4)
d.SetConnMaxIdleTime(5 * time.Minute)
if err := d.Ping(); err != nil {
return nil, fmt.Errorf("PostgreSQL: %w", err)
}
db := &DB{sql: &dbx{DB: d, pg: true}, path: sqlitePath}
if err := db.migrate(); err != nil {
return nil, fmt.Errorf("PostgreSQL: %w", err)
}
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&n)
if _, err := os.Stat(sqlitePath); n == 0 && err == nil {
src, err := OpenDB(sqlitePath)
if err != nil {
return nil, err
}
defer src.Close()
if err := copyDB(src, db); err != nil {
return nil, fmt.Errorf("перенос из SQLite: %w", err)
}
log.Printf("db: data copied from %s to PostgreSQL", sqlitePath)
}
return db, nil
}
// copyTables are copied parents first, for the foreign keys.
var copyTables = []string{"config", "admins", "agents", "admin_sessions", "audit", "ufw_rules", "admin_keys",
"forward_rules", "admin_agents", "ssh_hosts", "ai_chats", "ai_messages"}
// copyDB copies every row of src into dst (an empty PostgreSQL), in one
// transaction, then moves the id counters past the copied ids.
func copyDB(src, dst *DB) error {
tx, err := dst.sql.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range copyTables {
rows, err := src.sql.Query(`SELECT * FROM ` + t)
if err != nil {
return err
}
cols, _ := rows.Columns()
ph := make([]string, len(cols))
for i := range ph {
ph[i] = "$" + strconv.Itoa(i+1)
}
ins := `INSERT INTO ` + t + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(ph, ", ") + `)`
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
rows.Close()
return err
}
if _, err := tx.Exec(ins, vals...); err != nil {
rows.Close()
return fmt.Errorf("%s: %w", t, err)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
// The id counters past the copied ids, where a table has one.
for _, t := range copyTables {
var seq sql.NullString
err := dst.sql.DB.QueryRow(`SELECT pg_get_serial_sequence($1, column_name) FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'id' AND column_default LIKE 'nextval%'`, t).Scan(&seq)
if err != nil || !seq.Valid {
continue
}
if _, err := dst.sql.DB.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil {
return err
}
}
return nil
}
// Backend tells which database is in use and which is chosen for the next
// start.
func (db *DB) Backend() (now string, chosen backendConf) {
now = "sqlite"
if db.sql.pg {
now = "postgres"
}
return now, readBackend(db.path)
}
var errNoPGHost = errors.New("укажите хост, базу и пользователя PostgreSQL")
// POST /settings/db - the database for the next start; PostgreSQL is tried
// first and kept only if it answers.
func (s *Server) handleSetDB(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
back := func(q string) { http.Redirect(w, r, "/settings?"+q+"#db", http.StatusSeeOther) }
if r.FormValue("db_type") != "postgres" {
if err := writeBackend(s.db.path, backendConf{Type: "sqlite"}); err != nil {
back("err=" + url.QueryEscape(err.Error()))
return
}
back("db=saved")
return
}
old := readBackend(s.db.path).PG
c := PGConf{Host: strings.TrimSpace(r.FormValue("pg_host")), Name: strings.TrimSpace(r.FormValue("pg_name")),
User: strings.TrimSpace(r.FormValue("pg_user")), Password: r.FormValue("pg_password"), SSLMode: r.FormValue("pg_sslmode")}
c.Port, _ = strconv.Atoi(r.FormValue("pg_port"))
if c.Password == "" && old != nil {
c.Password = old.Password
}
if c.Host == "" || c.Name == "" || c.User == "" {
back("err=" + url.QueryEscape(errNoPGHost.Error()))
return
}
if err := pingPG(c); err != nil {
back("err=" + url.QueryEscape("PostgreSQL не отвечает: "+err.Error()))
return
}
if err := writeBackend(s.db.path, backendConf{Type: "postgres", PG: &c}); err != nil {
back("err=" + url.QueryEscape(err.Error()))
return
}
back("db=saved")
}
// POST /settings/db/restart - the server ends; its service (systemd,
// Restart=always) starts it again on the database chosen.
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
log.Printf("restart asked from the panel")
http.Redirect(w, r, "/settings?restarted=1#db", http.StatusSeeOther)
go func() {
time.Sleep(700 * time.Millisecond)
os.Exit(0)
}()
}