diff --git a/README.md b/README.md index 5e7b8a5..1a3b236 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,15 @@ Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа), а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить. +## База данных +По умолчанию - SQLite (файл из `-db`, по умолчанию `lpra.db`). В «Настройки › База данных» можно выбрать +PostgreSQL: хост, порт, база, пользователь, пароль, SSL; кнопка «Проверить и сохранить» подключается и +записывает выбор в `<база>.backend.json` рядом с SQLite-файлом (права 0600), «Перезапустить сервер» +завершает процесс - systemd (`Restart=always`) поднимает его уже на PostgreSQL. При первом запуске на +пустой PostgreSQL все данные копируются из SQLite-файла, сам файл остаётся как был (к нему можно +вернуться тем же выбором). История ИИ-ассистента тоже хранится в базе - у каждого пользователя +последние 200 сообщений. + ## Компоненты - **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS). - **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root. diff --git a/internal/server/dbpg.go b/internal/server/dbpg.go index 6abd3ea..f08e16c 100644 --- a/internal/server/dbpg.go +++ b/internal/server/dbpg.go @@ -167,16 +167,22 @@ func copyDB(src, dst *DB) error { 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 - _ = 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 - } + 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 tx.Commit() + return nil } // Backend tells which database is in use and which is chosen for the next diff --git a/internal/server/dbpg_test.go b/internal/server/dbpg_test.go new file mode 100644 index 0000000..78917b8 --- /dev/null +++ b/internal/server/dbpg_test.go @@ -0,0 +1,85 @@ +package server + +import ( + "encoding/json" + "os" + "testing" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// On a real PostgreSQL (LPRA_TEST_PG: {"host","port","name","user","password", +// "sslmode"} of an empty database): the first start copies the SQLite data +// over, and the panel's queries work there. +func TestPostgres(t *testing.T) { + raw := os.Getenv("LPRA_TEST_PG") + if raw == "" { + t.Skip("LPRA_TEST_PG not set") + } + var c PGConf + if err := json.Unmarshal([]byte(raw), &c); err != nil { + t.Fatal(err) + } + // SQLite with a user, a machine, a rule, a saved host and some talk. + s, _ := newTestServer(t) + a, _ := s.db.AdminByUsername("adm") + if _, err := s.db.CreateForwardRule(ForwardRule{AgentID: "x1", ClientPort: 80, HostPort: 18080, ListenAddr: "0.0.0.0", Proto: "tcp", NoUfw: true}); err != nil { + t.Fatal(err) + } + if _, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "pw"}); err != nil { + t.Fatal(err) + } + s.aiAppend(a.ID, "user", "привет") + _ = s.db.SetUserAgents(a.ID, []string{"x1"}) + + pg, err := OpenPostgres(c, s.db.path) + if err != nil { + t.Fatal(err) + } + defer pg.Close() + // Clean up for the next run. + defer func() { + for i := len(copyTables) - 1; i >= 0; i-- { + _, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`) + } + }() + p := &Server{db: pg} + if got, _ := pg.AdminByUsername("adm"); got == nil || got.ID != a.ID { + t.Fatalf("admin not copied: %+v", got) + } + if ag, _ := pg.GetAgent("x1"); ag == nil { + t.Fatal("agent not copied") + } + rules, _ := pg.ListForwardRules() + if len(rules) != 1 || !rules[0].NoUfw { + t.Fatalf("rules: %+v", rules) + } + tg := &proto.SSHTarget{Saved: p.listSSHHosts(a.ID)[0].ID} + if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" { + t.Fatalf("saved host: %+v %v", tg, err) + } + if h := p.aiHistory(a.ID, 10); len(h) != 1 || h[0].Content != "привет" { + t.Fatalf("talk: %+v", h) + } + // New rows go on after the copied ids. + id, err := pg.CreateUser("bob", "x", RoleUser) + if err != nil || id <= a.ID { + t.Fatalf("new user id %d, %v", id, err) + } + if err := pg.SetConfig("k", "v1"); err != nil { + t.Fatal(err) + } + if err := pg.SetConfig("k", "v2"); err != nil { + t.Fatal(err) + } + if v, _ := pg.GetConfig("k"); v != "v2" { + t.Fatalf("config upsert: %q", v) + } + if err := pg.SetUserAgents(id, []string{"x1", "x1"}); err != nil { + t.Fatalf("grants: %v", err) + } + p.aiAppend(a.ID, "assistant", "ответ") + if h := p.aiHistory(a.ID, 10); len(h) != 2 { + t.Fatalf("talk after: %+v", h) + } +}