Fix settings handler build
This commit is contained in:
1 parent
ce7c417884
commit
b272aed8fd
13 files changed
+458
-60
No files matched your search
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
|
||||
|
||||
Reference in new issue
Block a user