Fix settings handler build

This commit is contained in:
redtea committed 2026-10-15 22:48:03 +03:00
1 parent ce7c417884
commit b272aed8fd
13 files changed
+458 -60

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ func main() {
"do not mark cookies Secure (plain-HTTP local testing only)")
flag.Parse()
db, err := server.OpenDB(*dbPath)
db, err := server.OpenStore(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
+3 -3
View File
@@ -20,8 +20,8 @@ map $http_upgrade $connection_upgrade {
}
server {
listen 8445 ssl;
server_name REDACTED;
listen 443 ssl;
server_name lpra.minomix.ru;
ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/REDACTED/privkey.pem; # managed by Certbot
@@ -30,7 +30,7 @@ server {
# Files go through the panel in 4 MB pieces; 64m leaves room for a client
# that predates the pieces, which takes a file of up to 32 MB in one request.
client_max_body_size 64m;
client_max_body_size 120m;
location / {
proxy_pass http://127.0.0.1:8091;
BIN
View File
Binary file not shown.
+10 -5
View File
@@ -5,20 +5,25 @@ go 1.26
require (
github.com/coder/websocket v1.8.12
github.com/creack/pty v1.1.21
golang.org/x/crypto v0.31.0
golang.org/x/sys v0.28.0
github.com/jackc/pgx/v5 v5.7.5
golang.org/x/crypto v0.37.0
golang.org/x/sys v0.32.0
modernc.org/sqlite v1.24.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.3.0 // indirect
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
+36 -30
View File
@@ -2,14 +2,25 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
@@ -21,35 +32,30 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+10 -14
View File
@@ -11,7 +11,7 @@ import (
// DB wraps the SQLite connection and all queries used by the server.
type DB struct {
sql *sql.DB
sql *dbx
path string // the file; the key of saved secrets sits beside it
}
@@ -31,7 +31,7 @@ func OpenDB(path string) (*DB, error) {
if err := sdb.Ping(); err != nil {
return nil, err
}
db := &DB{sql: sdb, path: path}
db := &DB{sql: &dbx{DB: sdb}, path: path}
if err := db.migrate(); err != nil {
return nil, err
}
@@ -131,7 +131,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`CREATE TABLE IF NOT EXISTS ssh_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, user TEXT NOT NULL,
label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, login TEXT NOT NULL,
auth TEXT NOT NULL, secret TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS admin_agents (
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
@@ -142,6 +142,10 @@ CREATE TABLE IF NOT EXISTS forward_rules (
return err
}
}
// ssh_hosts.user (a word PostgreSQL keeps for itself) became login.
if !db.sql.pg {
_, _ = db.sql.Exec(`ALTER TABLE ssh_hosts RENAME COLUMN user TO login`)
}
return nil
}
@@ -178,13 +182,9 @@ func (db *DB) CreateAdmin(username, passHash string) error {
// CreateUser adds a panel user with the role given and returns its id.
func (db *DB) CreateUser(username, passHash, role string) (int64, error) {
res, err := db.sql.Exec(
return db.sql.insert(
`INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`,
username, passHash, time.Now().Unix(), role)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// Roles of panel users: an admin sees and runs everything; a user only the
@@ -595,14 +595,10 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
if r.Proto == "" {
r.Proto = "http"
}
res, err := db.sql.Exec(
return db.sql.insert(
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
if err != nil {
return 0, err
}
return res.LastInsertId()
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, boolInt(r.NoUfw), r.Label, time.Now().Unix())
}
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
+245
View File
@@ -0,0 +1,245 @@
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_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
}
}
for _, t := range copyTables {
var seq sql.NullString
_ = tx.QueryRow(`SELECT pg_get_serial_sequence($1, 'id')`, t).Scan(&seq)
if seq.Valid {
if _, err := tx.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil {
return err
}
}
}
return tx.Commit()
}
// 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)
}()
}
+93
View File
@@ -0,0 +1,93 @@
package server
import (
"database/sql"
"strconv"
"strings"
)
// dbx is the database the server talks to: SQLite, or PostgreSQL. The
// queries are written once, for SQLite; on PostgreSQL each is rewritten
// (pgSQL) on its way.
type dbx struct {
*sql.DB
pg bool
}
func (d *dbx) q(query string) string {
if d.pg {
return pgSQL(query)
}
return query
}
func (d *dbx) Exec(query string, args ...any) (sql.Result, error) {
return d.DB.Exec(d.q(query), args...)
}
func (d *dbx) Query(query string, args ...any) (*sql.Rows, error) {
return d.DB.Query(d.q(query), args...)
}
func (d *dbx) QueryRow(query string, args ...any) *sql.Row {
return d.DB.QueryRow(d.q(query), args...)
}
func (d *dbx) Begin() (*txx, error) {
tx, err := d.DB.Begin()
return &txx{Tx: tx, pg: d.pg}, err
}
// insert runs an INSERT and gives the new row's id (LastInsertId on SQLite,
// RETURNING id on PostgreSQL, which has no LastInsertId).
func (d *dbx) insert(query string, args ...any) (int64, error) {
if d.pg {
var id int64
err := d.DB.QueryRow(pgSQL(query)+" RETURNING id", args...).Scan(&id)
return id, err
}
res, err := d.DB.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
type txx struct {
*sql.Tx
pg bool
}
func (t *txx) Exec(query string, args ...any) (sql.Result, error) {
if t.pg {
query = pgSQL(query)
}
return t.Tx.Exec(query, args...)
}
// pgSQL rewrites a query for SQLite into PostgreSQL's: $n placeholders,
// INSERT OR IGNORE as ON CONFLICT DO NOTHING, and in table definitions
// BIGSERIAL keys, BIGINT numbers (times are Unix seconds), BYTEA blobs and
// ADD COLUMN IF NOT EXISTS.
func pgSQL(q string) string {
if strings.HasPrefix(q, "INSERT OR IGNORE ") {
q = "INSERT " + strings.TrimPrefix(q, "INSERT OR IGNORE ") + " ON CONFLICT DO NOTHING"
}
if strings.Contains(q, "CREATE TABLE") || strings.Contains(q, "ALTER TABLE") {
q = strings.ReplaceAll(q, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY")
q = strings.ReplaceAll(q, "INTEGER", "BIGINT")
q = strings.ReplaceAll(q, "BLOB", "BYTEA")
q = strings.ReplaceAll(q, "ADD COLUMN ", "ADD COLUMN IF NOT EXISTS ")
}
var b strings.Builder
n, quoted := 0, false
for _, r := range q {
switch {
case r == '\'':
quoted = !quoted
case r == '?' && !quoted:
n++
b.WriteString("$" + strconv.Itoa(n))
continue
}
b.WriteRune(r)
}
return b.String()
}
+19
View File
@@ -0,0 +1,19 @@
package server
import "testing"
// Queries written for SQLite, as PostgreSQL takes them.
func TestPGRebind(t *testing.T) {
for _, c := range [][2]string{
{`SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`},
{`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, `INSERT INTO admin_agents(admin_id, agent_id) VALUES($1, $2) ON CONFLICT DO NOTHING`},
{`SELECT '?' , ?`, `SELECT '?' , $1`},
{"CREATE TABLE IF NOT EXISTS x (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n n INTEGER NOT NULL,\n b BLOB NOT NULL)",
"CREATE TABLE IF NOT EXISTS x (\n id BIGSERIAL PRIMARY KEY,\n n BIGINT NOT NULL,\n b BYTEA NOT NULL)"},
{`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN IF NOT EXISTS run_user TEXT NOT NULL DEFAULT ''`},
} {
if got := pgSQL(c[0]); got != c[1] {
t.Errorf("\n got %s\nwant %s", got, c[1])
}
}
}
+3
View File
@@ -424,6 +424,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
code := s.db.InstallCode()
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
ufwRules, _ := s.db.ListUfwRules()
dbNow, dbChosen := s.db.Backend()
s.renderPage(w, r, "settings", map[string]any{
"Admin": s.currentAdmin(r),
"Host": host,
@@ -443,6 +444,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"ServerVersion": Version,
"DBNow": dbNow,
"DBChosen": dbChosen,
"UfwInstalled": ufwInstalled,
"UfwActive": ufwActive,
"UfwRunnable": ufwRunnable,
+2
View File
@@ -260,6 +260,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/ai/clear", s.requireAdmin(s.handleAIClear))
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
+3 -7
View File
@@ -114,16 +114,12 @@ func (s *Server) saveSSHHost(owner int64, h savedHost, sec sshSecret) (int64, er
if err != nil {
return 0, err
}
res, err := s.db.sql.Exec(`INSERT INTO ssh_hosts (admin_id, label, host, port, user, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`,
return s.db.sql.insert(`INSERT INTO ssh_hosts (admin_id, label, host, port, login, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`,
owner, h.Label, h.Host, h.Port, h.User, h.Auth, sealed)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Server) listSSHHosts(owner int64) []savedHost {
rows, err := s.db.sql.Query(`SELECT id, label, host, port, user, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner)
rows, err := s.db.sql.Query(`SELECT id, label, host, port, login, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner)
list := []savedHost{}
if err != nil {
return list
@@ -146,7 +142,7 @@ func (s *Server) fillSaved(owner int64, t *proto.SSHTarget) error {
}
var h savedHost
var sealed string
err := s.db.sql.QueryRow(`SELECT host, port, user, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner).
err := s.db.sql.QueryRow(`SELECT host, port, login, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner).
Scan(&h.Host, &h.Port, &h.User, &h.Auth, &sealed)
if err != nil {
return errors.New("сохранённый хост не найден")
@@ -116,6 +116,39 @@
{{end}}
</div>
<div class="card" id="db">
<h2>База данных</h2>
<p class="muted">Сейчас: <b>{{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}</b>.
{{if ne .DBNow .DBChosen.Type}}<span class="err" style="display:inline">Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.</span>{{end}}
При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).</p>
<form method="post" action="/settings/db" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Хранить в<select name="db_type" onchange="this.form.classList.toggle('pg', this.value === 'postgres')">
<option value="sqlite"{{if ne .DBChosen.Type "postgres"}} selected{{end}}>SQLite (файл рядом с сервером)</option>
<option value="postgres"{{if eq .DBChosen.Type "postgres"}} selected{{end}}>PostgreSQL</option>
</select></label>
<div class="pg-fields">
<div class="frow2"><label>Хост<input type="text" name="pg_host" value="{{with .DBChosen.PG}}{{.Host}}{{end}}" placeholder="localhost"></label>
<label class="port">Порт<input type="number" name="pg_port" min="1" max="65535" value="{{with .DBChosen.PG}}{{if .Port}}{{.Port}}{{end}}{{end}}" placeholder="5432"></label></div>
<label>База<input type="text" name="pg_name" value="{{with .DBChosen.PG}}{{.Name}}{{end}}" placeholder="lpra"></label>
<label>Пользователь<input type="text" name="pg_user" value="{{with .DBChosen.PG}}{{.User}}{{end}}" autocomplete="off"></label>
<label>Пароль<input type="password" name="pg_password" placeholder="{{with .DBChosen.PG}}{{if .Password}}задан - пусто, чтобы оставить{{end}}{{end}}" autocomplete="new-password"></label>
<label>SSL<select name="pg_sslmode">
{{$m := "prefer"}}{{with .DBChosen.PG}}{{if .SSLMode}}{{$m = .SSLMode}}{{end}}{{end}}
<option value="disable"{{if eq $m "disable"}} selected{{end}}>disable</option>
<option value="prefer"{{if eq $m "prefer"}} selected{{end}}>prefer</option>
<option value="require"{{if eq $m "require"}} selected{{end}}>require</option>
<option value="verify-full"{{if eq $m "verify-full"}} selected{{end}}>verify-full</option>
</select></label>
</div>
<button type="submit">Проверить и сохранить</button>
</form>
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger style="margin-top:10px">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="btn ghost">Перезапустить сервер</button>
</form>
</div>
<div class="card" id="ai">
<h2>ИИ-ассистент</h2>
<p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые