From e8a3428164fc2229afffd0607ecdf3733ee77384 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 18 Jun 2025 16:06:03 +0200 Subject: [PATCH 01/39] feat(api)!: move authentication to timescale Drop sqlite database. This change will help adding new features. This change is part of a refactor to improve security. --- api/main.go | 1 - api/storage/report_schema.sql.tmpl | 8 + api/storage/schema.sql | 16 -- api/storage/storage.go | 252 ++++++++++++++--------------- 4 files changed, 132 insertions(+), 145 deletions(-) delete mode 100644 api/storage/schema.sql diff --git a/api/main.go b/api/main.go index 379c50a0..10311de9 100644 --- a/api/main.go +++ b/api/main.go @@ -52,7 +52,6 @@ func setup() *gin.Engine { // init storage storage.Init() - storage.InitReportDb() // init socket connection socket.Init() diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index dbd1d64c..2b2cf748 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -9,6 +9,14 @@ -- Create the schema for the report database +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + display_name TEXT, + created TIMESTAMP NOT NULL +); + CREATE TABLE IF NOT EXISTS units ( uuid UUID PRIMARY KEY, name TEXT, diff --git a/api/storage/schema.sql b/api/storage/schema.sql deleted file mode 100644 index 64a07d5d..00000000 --- a/api/storage/schema.sql +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (C) 2024 Nethesis S.r.l. - * http://www.nethesis.it - info@nethesis.it - * - * SPDX-License-Identifier: GPL-2.0-only - * - * author: Edoardo Spadoni - */ - -CREATE TABLE accounts ( - `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - `username` TEXT NOT NULL UNIQUE, - `password` TEXT NOT NULL, - `display_name` TEXT, - `created` TIMESTAMP NOT NULL -); \ No newline at end of file diff --git a/api/storage/storage.go b/api/storage/storage.go index f708cfdb..2d20920a 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -27,14 +27,10 @@ import ( _ "github.com/mattn/go-sqlite3" ) -var db *sql.DB var dbpool *pgxpool.Pool var dbctx context.Context var err error -//go:embed schema.sql -var schemaSQL string - //go:embed report_schema.sql.tmpl var reportSchemaSQL string @@ -43,240 +39,240 @@ var grafanaUserSQL string var reportDbIsInitialized = false -func Instance() *sql.DB { - if db == nil { - db = Init() - } - return db -} +func Init() *pgxpool.Pool { + // Initialize PostgreSQL connection and schema + dbpool, dbctx = InitReportDb() -func Init() *sql.DB { - // check if file exists - initSchema := false - if _, err := os.Stat(configuration.Config.DataDir + "/db.sqlite"); os.IsNotExist(err) { - initSchema = true - } + // Migrate users from SQLite to Postgres if needed + MigrateUsersFromSqliteToPostgres() - // try connection - db, err = sql.Open("sqlite3", configuration.Config.DataDir+"/db.sqlite") + // Initialize PostgreSQL connection + dbctx = context.Background() + dbpool, err = pgxpool.New(dbctx, configuration.Config.ReportDbUri) if err != nil { - logs.Logs.Println("[ERR][STORAGE] error in storage db file creation:" + err.Error()) + logs.Logs.Println("[ERR][STORAGE] error in Postgres db connection:" + err.Error()) os.Exit(1) } - // check connectivity - err = db.Ping() + err = dbpool.Ping(dbctx) if err != nil { - logs.Logs.Println("[ERR][STORAGE] error in storage db connection:" + err.Error()) + logs.Logs.Println("[ERR][STORAGE] error in Postgres db ping:" + err.Error()) os.Exit(1) } - // init schema if true - if initSchema { - // execute create tables - _, errExecute := db.Exec(schemaSQL) - if errExecute != nil { - logs.Logs.Println("[ERR][STORAGE] error in storage file schema init:" + errExecute.Error()) - } + // Check if admin user exists + var exists bool + err = dbpool.QueryRow(dbctx, "SELECT EXISTS (SELECT 1 FROM accounts WHERE username = $1)", configuration.Config.AdminUsername).Scan(&exists) + if err != nil { + logs.Logs.Println("[ERR][STORAGE] error checking admin user: " + err.Error()) + os.Exit(1) } - - // check if user admin exists - results, _ := GetAccount(configuration.Config.AdminUsername) - exists := len(results) > 0 - - // add admin account, if not exists if !exists { - // define admin account admin := models.Account{ - ID: 1, Username: configuration.Config.AdminUsername, Password: configuration.Config.AdminPassword, DisplayName: "Administrator", Created: time.Now(), } - - // add admin account _ = AddAccount(admin) } - return db + return dbpool } -func AddAccount(account models.Account) error { - // get db - db := Instance() +// MigrateUsersFromSqliteToPostgres migrates users from SQLite to PostgreSQL if needed +func MigrateUsersFromSqliteToPostgres() { + // 1. Check if SQLite DB exists + sqlitePath := configuration.Config.DataDir + "/db.sqlite" + if _, err := os.Stat(sqlitePath); os.IsNotExist(err) { + return // No SQLite DB, nothing to migrate + } + + // 2. Open SQLite DB + sqliteDB, err := sql.Open("sqlite3", sqlitePath) + if err != nil { + logs.Logs.Println("[ERR][MIGRATION] cannot open SQLite DB: " + err.Error()) + return + } + defer sqliteDB.Close() + + // 3. Check if SQLite has users + rows, err := sqliteDB.Query("SELECT id, username, password, display_name, created FROM accounts") + if err != nil { + logs.Logs.Println("[ERR][MIGRATION] cannot query SQLite accounts: " + err.Error()) + return + } + defer rows.Close() + var users []models.Account + for rows.Next() { + var acc models.Account + var createdStr string + if err := rows.Scan(&acc.ID, &acc.Username, &acc.Password, &acc.DisplayName, &createdStr); err != nil { + logs.Logs.Println("[ERR][MIGRATION] error scanning SQLite user: " + err.Error()) + continue + } + acc.Created, _ = time.Parse(time.RFC3339, createdStr) + users = append(users, acc) + } + if len(users) == 0 { + return // No users to migrate + } + + // 4. Check if admin user exists in Postgres + pgpool, pgctx := ReportInstance() + var adminExists bool + err = pgpool.QueryRow(pgctx, `SELECT EXISTS (SELECT 1 FROM accounts WHERE username = $1)`, configuration.Config.AdminUsername).Scan(&adminExists) + if err != nil { + logs.Logs.Println("[ERR][MIGRATION] error checking admin user in Postgres: " + err.Error()) + return + } + if adminExists { + return // Admin user exists, nothing to do + } + + // 5. Create accounts table in Postgres + _, err = pgpool.Exec(pgctx, `CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + display_name TEXT, + created TIMESTAMP NOT NULL + )`) + if err != nil { + logs.Logs.Println("[ERR][MIGRATION] error creating accounts table in Postgres: " + err.Error()) + return + } - // define query - _, err := db.Exec( - "INSERT INTO accounts (id, username, password, display_name, created) VALUES (null, ?, ?, ?, ?)", + // 6. Insert users into Postgres + for _, acc := range users { + _, err := pgpool.Exec(pgctx, `INSERT INTO accounts (username, password, display_name, created) VALUES ($1, $2, $3, $4) ON CONFLICT (username) DO NOTHING`, + acc.Username, acc.Password, acc.DisplayName, acc.Created) + if err != nil { + logs.Logs.Println("[ERR][MIGRATION] error inserting user into Postgres: " + err.Error()) + } + } + logs.Logs.Println("[INFO][MIGRATION] migrated", len(users), "users from SQLite to Postgres") + + // 7. Rename SQLite DB to avoid future migrations + err = os.Rename(sqlitePath, sqlitePath+".bak") + if err != nil { + logs.Logs.Println("[ERR][MIGRATION] error renaming SQLite DB: " + err.Error()) + } +} + +// Refactored user functions to use PostgreSQL +func AddAccount(account models.Account) error { + pgpool, pgctx := ReportInstance() + _, err := pgpool.Exec(pgctx, + "INSERT INTO accounts (username, password, display_name, created) VALUES ($1, $2, $3, $4)", account.Username, utils.HashPassword(account.Password), account.DisplayName, - account.Created.Format(time.RFC3339), + account.Created, ) - - // check error if err != nil { logs.Logs.Println("[ERR][STORAGE][ADD_ACCOUNT] error in insert accounts query: " + err.Error()) } - return err } func UpdateAccount(accountID string, account models.AccountUpdate) error { - // get db - db := Instance() - - // define error + pgpool, pgctx := ReportInstance() var err error - - // check props if len(account.Password) > 0 { - // define and execute query - query := "UPDATE accounts set password = ? WHERE id = ?" - _, err = db.Exec( - query, + _, err = pgpool.Exec(pgctx, + "UPDATE accounts set password = $1 WHERE id = $2", utils.HashPassword(account.Password), accountID, ) } if len(account.DisplayName) > 0 { - // define and execute query - query := "UPDATE accounts set display_name = ? WHERE id = ?" - _, err = db.Exec( - query, + _, err = pgpool.Exec(pgctx, + "UPDATE accounts set display_name = $1 WHERE id = $2", account.DisplayName, accountID, ) } - - // check error if err != nil { - logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in insert accounts query: " + err.Error()) + logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update accounts query: " + err.Error()) } - return err } func IsAdmin(accountUsername string) (bool, string) { - // get db - db := Instance() - - // define query - var id string - query := "SELECT id FROM accounts where username = ? LIMIT 1" - err := db.QueryRow(query, accountUsername).Scan(&id) - - // check error + pgpool, pgctx := ReportInstance() + var id int + err := pgpool.QueryRow(pgctx, "SELECT id FROM accounts where username = $1 LIMIT 1", accountUsername).Scan(&id) if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_PASSWORD] error in query execution:" + err.Error()) } - - // check if user is admin or other user - return id == "1", id + return id == 1, string(rune(id)) } func GetAccounts() ([]models.Account, error) { - // get db - db := Instance() - - // define query - query := "SELECT id, username, display_name, created FROM accounts" - rows, err := db.Query(query) + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, created FROM accounts") if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query execution:" + err.Error()) } defer rows.Close() - - // loop rows var results []models.Account for rows.Next() { var accountRow models.Account if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Created); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query row extraction" + err.Error()) } - accountStatus, _ := utils.GetUserStatus(accountRow.Username) accountRow.TwoFA = accountStatus == "1" - - // append results results = append(results, accountRow) } - - // return results return results, err } func GetAccount(accountID string) ([]models.Account, error) { - // get db - db := Instance() - - // define query - query := "SELECT id, username, display_name, created FROM accounts where id = ?" - rows, err := db.Query(query, accountID) + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, created FROM accounts where id = $1", accountID) if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNT] error in query execution:" + err.Error()) } defer rows.Close() - - // loop rows var results []models.Account for rows.Next() { var accountRow models.Account if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Created); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNT] error in query row extraction" + err.Error()) } - - // append results results = append(results, accountRow) } - - // return results return results, err } func GetPassword(accountUsername string) string { - // get db - db := Instance() - - // define query + pgpool, pgctx := ReportInstance() var password string - query := "SELECT password FROM accounts where username = ? LIMIT 1" - err := db.QueryRow(query, accountUsername).Scan(&password) + err := pgpool.QueryRow(pgctx, "SELECT password FROM accounts where username = $1 LIMIT 1", accountUsername).Scan(&password) if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_PASSWORD] error in query execution:" + err.Error()) } - - // return password return password } func DeleteAccount(accountID string) error { - // get db - db := Instance() - - // define query - query := "DELETE FROM accounts where id = ?" - _, err = db.Exec(query, accountID) + pgpool, pgctx := ReportInstance() + _, err := pgpool.Exec(pgctx, "DELETE FROM accounts where id = $1", accountID) if err != nil { logs.Logs.Println("[ERR][STORAGE][DELETE_ACCOUNT] error in query execution:" + err.Error()) } - return err } func UpdatePassword(accountUsername string, newPassword string) error { - // get db - db := Instance() - - // define query - query := "UPDATE accounts set password = ? WHERE username = ?" - _, err = db.Exec( - query, + pgpool, pgctx := ReportInstance() + _, err := pgpool.Exec(pgctx, + "UPDATE accounts set password = $1 WHERE username = $2", utils.HashPassword(newPassword), accountUsername, ) - return err } From 5f6c637972ed19d74d1356a4093fff15ffffb692 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 18 Jun 2025 16:54:50 +0200 Subject: [PATCH 02/39] fix(ci): start timescale for tests --- .github/workflows/test.yml | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94534ba7..727fd0a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,16 +12,24 @@ jobs: name: API Tests runs-on: ubuntu-24.04 steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Checkout uses: actions/checkout@v4 - - name: Run tests - uses: docker/build-push-action@v6 + - name: Install Podman + run: | + sudo apt-get update + sudo apt-get install -y podman + - name: Start TimescaleDB + run: | + podman run --rm -d --name timescaledb -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_USER=report timescale/timescaledb-ha:pg17 + # Wait for DB to be ready + for i in {1..30}; do + podman exec timescaledb pg_isready -U report && break + sleep 1 + done + - name: Setup Go + uses: actions/setup-go@v5 with: - push: false - context: api - target: test - cache-to: type=gha,mode=max,scope=api-testing - cache-from: type=gha,scope=api-testing - file: api/Containerfile + go-version: '1.24.4' + - name: Test with the Go CLI + run: cd api && go test From 7680463e9b5fb67d52856037684934ce4784c7ea Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 19 Jun 2025 09:36:35 +0200 Subject: [PATCH 03/39] chore(api): add tests for accounts and 2FA --- .github/workflows/test.yml | 2 +- api/go.mod | 4 +- api/go.sum | 4 ++ api/main_test.go | 128 ++++++++++++++++++++++++++++++++++++- 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 727fd0a0..661e6eed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - name: Install Podman run: | sudo apt-get update - sudo apt-get install -y podman + sudo apt-get install -y podman oathtool - name: Start TimescaleDB run: | podman run --rm -d --name timescaledb -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_USER=report timescale/timescaledb-ha:pg17 diff --git a/api/go.mod b/api/go.mod index 1fc71aaf..b5e7c2c0 100644 --- a/api/go.mod +++ b/api/go.mod @@ -25,10 +25,9 @@ require ( require ( github.com/bytedance/sonic v1.13.3 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -50,6 +49,7 @@ require ( github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect diff --git a/api/go.sum b/api/go.sum index 3f197177..f4dd95a3 100644 --- a/api/go.sum +++ b/api/go.sum @@ -22,6 +22,8 @@ github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqp github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8= github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= @@ -322,6 +324,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= diff --git a/api/main_test.go b/api/main_test.go index 3c31ca25..15ed58a9 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -8,9 +8,11 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" ) @@ -21,6 +23,8 @@ func TestMainEndpoints(t *testing.T) { var token string t.Run("TestLoginEndpoint", func(t *testing.T) { + // Remove 2FA config from previous tests + os.RemoveAll(configuration.Config.SecretsDir + "/" + "admin") w := httptest.NewRecorder() var jsonResponse map[string]interface{} body := `{"username": "admin", "password": "admin"}` @@ -73,6 +77,62 @@ func TestMainEndpoints(t *testing.T) { assert.Equal(t, data["accounts"].([]interface{})[0].(map[string]interface{})["two_fa"], false) }) + t.Run("TestAddUpdateDeleteAccount", func(t *testing.T) { + w := httptest.NewRecorder() + // Add account + addBody := `{"username": "testuser", "password": "testpass", "display_name": "Test User"}` + addReq, _ := http.NewRequest("POST", "/accounts", bytes.NewBuffer([]byte(addBody))) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, addReq) + assert.Equal(t, http.StatusCreated, w.Code) + // Get accounts to find the new account's ID + w = httptest.NewRecorder() + getReq, _ := http.NewRequest("GET", "/accounts", nil) + getReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, getReq) + var getResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&getResp) + accounts := getResp["data"].(map[string]interface{})["accounts"].([]interface{}) + var testAccountID string + for _, acc := range accounts { + accMap := acc.(map[string]interface{}) + if accMap["username"] == "testuser" { + testAccountID = fmt.Sprintf("%v", accMap["id"]) + } + } + assert.NotEmpty(t, testAccountID) + // Update account display name + updateBody := `{"display_name": "Updated User"}` + updateReq, _ := http.NewRequest("PUT", "/accounts/"+testAccountID, bytes.NewBuffer([]byte(updateBody))) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + router.ServeHTTP(w, updateReq) + assert.Equal(t, http.StatusOK, w.Code) + // Get account and check display name + w = httptest.NewRecorder() + getOneReq, _ := http.NewRequest("GET", "/accounts/"+testAccountID, nil) + getOneReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, getOneReq) + var getOneResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&getOneResp) + accData := getOneResp["data"].(map[string]interface{})["account"].(map[string]interface{}) + assert.Equal(t, "Updated User", accData["display_name"]) + // Delete account + w = httptest.NewRecorder() + deleteReq, _ := http.NewRequest("DELETE", "/accounts/"+testAccountID, nil) + deleteReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, deleteReq) + assert.Equal(t, http.StatusOK, w.Code) + // Ensure account is deleted + w = httptest.NewRecorder() + getOneReq, _ = http.NewRequest("GET", "/accounts/"+testAccountID, nil) + getOneReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, getOneReq) + assert.Equal(t, http.StatusNotFound, w.Code) + }) + t.Run("TestRegisterUnitEndpoint", func(t *testing.T) { // create credentials directory if _, err := os.Stat(configuration.Config.CredentialsDir); os.IsNotExist(err) { @@ -124,6 +184,72 @@ func TestMainEndpoints(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) }) + + // 2FA test: enable, verify with OTP, verify with recovery code, remove + t.Run("Test2FAEnableVerifyRemove", func(t *testing.T) { + w := httptest.NewRecorder() + // Execute login to get token + var jsonResponse map[string]interface{} + body := `{"username": "admin", "password": "admin"}` + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer([]byte(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + json.NewDecoder(w.Body).Decode(&jsonResponse) + token = jsonResponse["token"].(string) + assert.Equal(t, http.StatusOK, w.Code) + assert.NotEmpty(t, token) + + // Enable 2FA (get QR code and secret) + qrReq, _ := http.NewRequest("GET", "/2fa/qr-code", nil) + qrReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, qrReq) + assert.Equal(t, http.StatusOK, w.Code) + var qrResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&qrResp) + secret := qrResp["data"].(map[string]interface{})["key"].(string) + assert.NotEmpty(t, secret) + + otp, err := totp.GenerateCode(secret, time.Now()) + assert.NoError(t, err) + assert.NotEmpty(t, otp) + + // Verify 2FA login with OTP code + otpBody := map[string]string{"username": "admin", "token": token, "otp": otp} + otpBodyBytes, _ := json.Marshal(otpBody) + otpReq, _ := http.NewRequest("POST", "/2fa/otp-verify", bytes.NewBuffer(otpBodyBytes)) + otpReq.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, otpReq) + assert.Equal(t, http.StatusOK, w.Code) + + // Get recovery codes + w = httptest.NewRecorder() + statusReq, _ := http.NewRequest("GET", "/2fa", nil) + statusReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, statusReq) + assert.Equal(t, http.StatusOK, w.Code) + var statusResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&statusResp) + recoveryCodes := statusResp["data"].(map[string]interface{})["recovery_codes"].([]interface{}) + assert.NotEmpty(t, recoveryCodes) + recoveryCode := recoveryCodes[0].(string) + + // Verify 2FA login with recovery code + recBody := map[string]string{"username": "admin", "token": token, "otp": recoveryCode} + recBodyBytes, _ := json.Marshal(recBody) + recReq, _ := http.NewRequest("POST", "/2fa/otp-verify", bytes.NewBuffer(recBodyBytes)) + recReq.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, recReq) + assert.Equal(t, http.StatusOK, w.Code) + + // Remove 2FA + w = httptest.NewRecorder() + delReq, _ := http.NewRequest("DELETE", "/2fa", nil) + delReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, delReq) + assert.Equal(t, http.StatusOK, w.Code) + }) } func setupRouter() *gin.Engine { @@ -145,7 +271,7 @@ func setupRouter() *gin.Engine { os.Setenv("REPORT_DB_URI", "postgres://report:password@127.0.0.1:5432/report") os.Setenv("GRAFANA_POSTGRES_PASSWORD", "password") os.Setenv("ISSUER_2FA", "test") - os.Setenv("SECRETS_DIR", "./data") + os.Setenv("SECRETS_DIR", "./secrets") // create directory configuration directory if _, err := os.Stat(os.Getenv("DATA_DIR")); os.IsNotExist(err) { From ba4e6b9f8e1b2beb2c80e5ce7f4e81d422973693 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 19 Jun 2025 16:38:09 +0200 Subject: [PATCH 04/39] feat(api)!: move 2FA to database Changes: - store 2FA secret and recovery codes inside database to consolidate the data - drop oathtool external binary, use battle-tested pquerna/otp lib - encrypt 2FA secrets using AES This change is part of a refactor to improve security. --- api/README.md | 2 + api/configuration/configuration.go | 15 +- api/main_test.go | 85 +++++++++- api/methods/auth.go | 245 +++++++---------------------- api/middleware/middleware.go | 4 +- api/storage/report_schema.sql.tmpl | 2 + api/storage/storage.go | 87 +++++++++- api/utils/utils.go | 72 ++++++++- 8 files changed, 312 insertions(+), 200 deletions(-) diff --git a/api/README.md b/api/README.md index 69aa2736..007527b1 100644 --- a/api/README.md +++ b/api/README.md @@ -62,6 +62,8 @@ CGO_ENABLED=0 go build - `SENSITIVE_LIST`: list of sensitive information to be redacted in logs - `VALID_SUBSCRIPTION`: valid subscription status - _default_: `false` +- `ENCRYPTION_KEY`: key to encrypt/decrypt sensitive data, it must be 32 bytes long + ## APIs ### Auth diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 4e067592..66ffae07 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -41,7 +41,7 @@ type Configuration struct { CredentialsDir string `json:"credentials_dir"` DataDir string `json:"data_dir"` Issuer2FA string `json:"issuer_2fa"` - SecretsDir string `json:"secrets_dir"` + SecretsDir string `json:"secrets_dir"` // Deprecated: it can be removed in the future PromtailAddress string `json:"promtail_address"` PromtailPort string `json:"promtail_port"` @@ -70,6 +70,8 @@ type Configuration struct { GrafanaPostgresPassword string `json:"grafana_postgres_password"` RetentionDays string `json:"retention_days"` + + EncryptionKey string `json:"encryption_key"` } var Config = Configuration{} @@ -301,4 +303,15 @@ func Init() { } else { Config.RetentionDays = "60" } + + if os.Getenv("ENCRYPTION_KEY") != "" { + Config.EncryptionKey = os.Getenv("ENCRYPTION_KEY") + if len(Config.EncryptionKey) != 32 { + logs.Logs.Println("[CRITICAL][ENV] ENCRYPTION_KEY variable is not 32 bytes") + os.Exit(1) + } + } else { + logs.Logs.Println("[CRITICAL][ENV] ENCRYPTION_KEY variable is empty") + os.Exit(1) + } } diff --git a/api/main_test.go b/api/main_test.go index 15ed58a9..06ed3d6d 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -10,14 +10,84 @@ import ( "testing" "time" + "crypto/rand" + "github.com/NethServer/nethsecurity-controller/api/configuration" + "github.com/NethServer/nethsecurity-controller/api/utils" "github.com/gin-gonic/gin" "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" ) -func TestMainEndpoints(t *testing.T) { +// TestAESGCMEncryption tests AES-GCM encryption and decryption. +func TestAESGCMEncryption(t *testing.T) { + key := []byte("12345678901234567890123456789012") // AES-256, 32 byte + _, err := rand.Read(key) + if err != nil { + t.Fatalf("failed to generate random key: %v", err) + } + plaintext := []byte("Hello, AES-GCM encryption!") + + ciphertext, err := utils.EncryptAESGCM(plaintext, key) + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + if bytes.Equal(ciphertext, plaintext) { + t.Error("ciphertext should not match plaintext") + } + + decrypted, err := utils.DecryptAESGCM(ciphertext, key) + if err != nil { + t.Fatalf("decryption failed: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("decrypted text does not match original. got: %s, want: %s", decrypted, plaintext) + } + + // Test with wrong key + wrongKey := make([]byte, 32) + _, err = rand.Read(wrongKey) + if err != nil { + t.Fatalf("failed to generate wrong key: %v", err) + } + _, err = utils.DecryptAESGCM(ciphertext, wrongKey) + if err == nil { + t.Error("decryption should fail with wrong key") + } +} + +// TestAESGCMToString tests EncryptAESGCMToString and DecryptAESGCMFromString helpers. +func TestAESGCMToString(t *testing.T) { + key := []byte("12345678901234567890123456789012") // 32 bytes + + plaintext := []byte("Store this in DB as base64!") + ciphertextB64, err := utils.EncryptAESGCMToString(plaintext, key) + if err != nil { + t.Fatalf("EncryptAESGCMToString failed: %v", err) + } + if ciphertextB64 == "" { + t.Error("ciphertextB64 should not be empty") + } + + decrypted, err := utils.DecryptAESGCMFromString(ciphertextB64, key) + if err != nil { + t.Fatalf("DecryptAESGCMFromString failed: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("decrypted text does not match original. got: %s, want: %s", decrypted, plaintext) + } + + // Test with wrong key + wrongKey := []byte("abcdefghabcdefghabcdefghabcdefgh") // 32 bytes + _, err = utils.DecryptAESGCMFromString(ciphertextB64, wrongKey) + if err == nil { + t.Error("decryption should fail with wrong key") + } +} + +func TestMainEndpoints(t *testing.T) { + // Tests assume to run on a clean database, otherwise 2FA tests will fail gin.SetMode(gin.TestMode) router := setupRouter() var token string @@ -249,6 +319,18 @@ func TestMainEndpoints(t *testing.T) { delReq.Header.Set("Authorization", "Bearer "+token) router.ServeHTTP(w, delReq) assert.Equal(t, http.StatusOK, w.Code) + + // Check 2FA is disabled + w = httptest.NewRecorder() + statusReq, _ = http.NewRequest("GET", "/2fa", nil) + statusReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, statusReq) + assert.Equal(t, http.StatusOK, w.Code) + var statusResp2fa map[string]interface{} + json.NewDecoder(w.Body).Decode(&statusResp2fa) + assert.Equal(t, false, statusResp2fa["data"].(map[string]interface{})["status"]) + // recovery codes should be empty + assert.Equal(t, []interface{}{}, statusResp2fa["data"].(map[string]interface{})["recovery_codes"]) }) } @@ -272,6 +354,7 @@ func setupRouter() *gin.Engine { os.Setenv("GRAFANA_POSTGRES_PASSWORD", "password") os.Setenv("ISSUER_2FA", "test") os.Setenv("SECRETS_DIR", "./secrets") + os.Setenv("ENCRYPTION_KEY", "12345678901234567890123456789012") // create directory configuration directory if _, err := os.Stat(os.Getenv("DATA_DIR")); os.IsNotExist(err) { diff --git a/api/methods/auth.go b/api/methods/auth.go index dd1f044f..d967a730 100644 --- a/api/methods/auth.go +++ b/api/methods/auth.go @@ -13,24 +13,28 @@ import ( "crypto/rand" "encoding/base32" "fmt" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" + "github.com/Jeffail/gabs/v2" "github.com/NethServer/nethsecurity-controller/api/logs" "github.com/NethServer/nethsecurity-controller/api/models" "github.com/NethServer/nethsecurity-controller/api/response" + "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" jwt "github.com/appleboy/gin-jwt/v2" - "github.com/dgryski/dgoogauth" "github.com/fatih/structs" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" jwtl "github.com/golang-jwt/jwt" - "net/http" - "net/url" - "os" - "os/exec" - "strings" "github.com/NethServer/nethsecurity-controller/api/configuration" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" ) func CheckTokenValidation(username string, token string) bool { @@ -102,7 +106,7 @@ func OTPVerify(c *gin.Context) { } // get secret for the user - secret := GetUserSecret(jsonOTP.Username) + secret := storage.GetUserOtpSecret(jsonOTP.Username) // check secret if len(secret) == 0 { @@ -114,19 +118,19 @@ func OTPVerify(c *gin.Context) { return } - // set OTP configuration - otpc := &dgoogauth.OTPConfig{ - Secret: secret, - WindowSize: 3, - HotpCounter: 0, - } - // verifiy OTP - result, err := otpc.Authenticate(jsonOTP.OTP) - if err != nil || !result { + valid := false + err := error(nil) + valid, err = totp.ValidateCustom(jsonOTP.OTP, secret, time.Now(), totp.ValidateOpts{ + Period: 30, + Skew: 3, // window size + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) + if err != nil || !valid { // check if OTP is a recovery code - recoveryCodes := GetRecoveryCodes(jsonOTP.Username) + recoveryCodes := storage.GetRecoveryCodes(jsonOTP.Username) if !utils.Contains(jsonOTP.OTP, recoveryCodes) { // compose validation error @@ -166,53 +170,22 @@ func OTPVerify(c *gin.Context) { } - // check if 2FA was disabled - status, _ := os.ReadFile(configuration.Config.SecretsDir + "/" + jsonOTP.Username + "/status") - statusOld := strings.TrimSpace(string(status[:])) - - // then clean all previous tokens - if statusOld == "0" || statusOld == "" { - // open file - f, _ := os.OpenFile(configuration.Config.TokensDir+"/"+jsonOTP.Username, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - defer f.Close() - - // write file with tokens - _, err := f.WriteString("") - - // check error - if err != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, - Message: "clean previous tokens error", - Data: err, - })) - return - } - } - - // set auth token to valid - if !SetTokenValidation(jsonOTP.Username, jsonOTP.Token) { + // Just fail if 2FA is not enabled + if !storage.Is2FAEnabled(jsonOTP.Username) { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, - Message: "token validation set error", + Message: "2fa_disabled", Data: "", })) return } - // set 2FA to enabled - f, _ := os.OpenFile(configuration.Config.SecretsDir+"/"+jsonOTP.Username+"/status", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - defer f.Close() - - // write file with 2fa status - _, err = f.WriteString("1") - - // check error - if err != nil { + // set auth token to valid + if !SetTokenValidation(jsonOTP.Username, jsonOTP.Token) { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, - Message: "status set error", - Data: err, + Message: "token validation set error", + Data: "", })) return } @@ -263,76 +236,8 @@ func ValidateAuth(tokenString string, ensureTokenExists bool) bool { return false } -func GetUserSecret(username string) string { - // get secret - secret, err := os.ReadFile(configuration.Config.SecretsDir + "/" + username + "/secret") - - // handle error - if err != nil { - return "" - } - - // return string - return string(secret[:]) -} - -func GetRecoveryCodes(username string) []string { - // create empty array - var recoveryCodes []string - - // check if recovery codes exists - codesB, _ := os.ReadFile(configuration.Config.SecretsDir + "/" + username + "/codes") - - // check length - if len(string(codesB[:])) == 0 { - - // get secret - secret := GetUserSecret(username) - - // get recovery codes - if len(string(secret)) > 0 { - // execute oathtool to get recovery codes - out, err := exec.Command("/usr/bin/oathtool", "-w", "4", "-b", secret).Output() - - // check errors - if err != nil { - return recoveryCodes - } - - // open file - f, _ := os.OpenFile(configuration.Config.SecretsDir+"/"+username+"/codes", os.O_WRONLY|os.O_CREATE, 0600) - defer f.Close() - - // write file with secret - _, _ = f.WriteString(string(out[:])) - - // assign binary output - codesB = out - } - - } - - // parse output - recoveryCodes = strings.Split(string(codesB[:]), "\n") - - // remove empty element, the last one - if recoveryCodes[len(recoveryCodes)-1] == "" { - recoveryCodes = recoveryCodes[:len(recoveryCodes)-1] - } - - // return codes - return recoveryCodes -} - func UpdateRecoveryCodes(username string, codes []string) bool { - // open file - f, _ := os.OpenFile(configuration.Config.SecretsDir+"/"+username+"/codes", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - defer f.Close() - - // write file with secret - codes = append(codes, "") - _, err := f.WriteString(strings.Join(codes[:], "\n")) - + err := storage.SetUserRecoveryCodes(username, codes) // check error return err == nil } @@ -340,29 +245,23 @@ func UpdateRecoveryCodes(username string, codes []string) bool { func Get2FAStatus(c *gin.Context) { // get claims from token claims := jwt.ExtractClaims(c) - - // get status - statusS, err := utils.GetUserStatus(claims["id"].(string)) - - // handle response - var message = "2FA set for this user" + var message string var recoveryCodes []string - if !(statusS == "1") || err != nil { + twofa_enabled := storage.Is2FAEnabled(claims["id"].(string)) + if twofa_enabled { + message = "2FA set for this user" + recoveryCodes = storage.GetRecoveryCodes(claims["id"].(string)) + } else { message = "2FA not set for this user" - statusS = "0" - } - - // get recovery codes - if statusS == "1" { - recoveryCodes = GetRecoveryCodes(claims["id"].(string)) + recoveryCodes = []string{} } // return response c.JSON(http.StatusOK, structs.Map(response.StatusOK{ Code: 200, Message: message, - Data: gin.H{"status": statusS == "1", "recovery_codes": recoveryCodes}, + Data: gin.H{"status": twofa_enabled, "recovery_codes": recoveryCodes}, })) } @@ -370,41 +269,24 @@ func Del2FAStatus(c *gin.Context) { // get claims from token claims := jwt.ExtractClaims(c) - // revocate secret - errRevocate := os.Remove(configuration.Config.SecretsDir + "/" + claims["id"].(string) + "/secret") - if errRevocate != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 403, - Message: "error in revocate 2FA for user", - Data: nil, - })) - return - } - - // revocate recovery codes - errRevocateCodes := os.Remove(configuration.Config.SecretsDir + "/" + claims["id"].(string) + "/codes") - if errRevocateCodes != nil { + // revoke 2FA secret + err := storage.SetUserOtpSecret(claims["id"].(string), "") + if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 403, - Message: "error in delete 2FA recovery codes", + Code: 400, + Message: "error in revoke 2FA for user", Data: nil, })) return } - // set 2FA to disabled - f, _ := os.OpenFile(configuration.Config.SecretsDir+"/"+claims["id"].(string)+"/status", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - defer f.Close() - - // write file with tokens - _, err := f.WriteString("0") - - // check error + // revoke 2FA recovery codes + err = storage.SetUserRecoveryCodes(claims["id"].(string), []string{}) if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, - Message: "2FA not revocated", - Data: "", + Message: "error in revoke 2FA recovery codes for user", + Data: nil, })) return } @@ -464,6 +346,8 @@ func QRCode(c *gin.Context) { // print url URL.RawQuery = params.Encode() + storage.SetUserRecoveryCodes(account, generateRecoveryCodes()) + // response c.JSON(http.StatusOK, structs.Map(response.StatusOK{ Code: 200, @@ -473,30 +357,19 @@ func QRCode(c *gin.Context) { } func SetUserSecret(username string, secret string) (bool, string) { - // get secret - secretB, _ := os.ReadFile(configuration.Config.SecretsDir + "/" + username + "/secret") - - // check error - if len(string(secretB[:])) == 0 { - // check if dir exists, otherwise create it - if _, errD := os.Stat(configuration.Config.SecretsDir + "/" + username); os.IsNotExist(errD) { - _ = os.MkdirAll(configuration.Config.SecretsDir+"/"+username, 0700) - } - - // open file - f, _ := os.OpenFile(configuration.Config.SecretsDir+"/"+username+"/secret", os.O_WRONLY|os.O_CREATE, 0600) - defer f.Close() - - // write file with secret - _, err := f.WriteString(secret) + err := storage.SetUserOtpSecret(username, secret) + return err == nil, secret +} - // check error +func generateRecoveryCodes() []string { + recoveryCodes := make([]string, 10) + for i := 0; i < 10; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { - return false, "" + recoveryCodes[i] = "000000" // fallback in case of error + continue } - - return true, secret + recoveryCodes[i] = fmt.Sprintf("%06d", num.Int64()) } - - return true, string(secretB[:]) + return recoveryCodes } diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 7bbebc7b..3bd5e1a5 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -104,14 +104,14 @@ func InitJWT() *jwt.GinJWTMiddleware { } // check if user require 2fa - status, _ := utils.GetUserStatus(user.Username) + status := storage.Is2FAEnabled(user.Username) // create claims map return jwt.MapClaims{ identityKey: user.Username, "role": role, "actions": []string{}, - "2fa": status == "1", + "2fa": status, } } diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index 2b2cf748..e9c7d7bd 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS accounts ( username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, display_name TEXT, + otp_secret TEXT, + otp_recovery_codes TEXT, created TIMESTAMP NOT NULL ); diff --git a/api/storage/storage.go b/api/storage/storage.go index 2d20920a..18620171 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -16,6 +16,7 @@ import ( _ "embed" "html/template" "os" + "strings" "time" "github.com/NethServer/nethsecurity-controller/api/configuration" @@ -136,6 +137,8 @@ func MigrateUsersFromSqliteToPostgres() { username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, display_name TEXT, + otp_secret TEXT, + otp_recovery_codes TEXT, created TIMESTAMP NOT NULL )`) if err != nil { @@ -145,8 +148,24 @@ func MigrateUsersFromSqliteToPostgres() { // 6. Insert users into Postgres for _, acc := range users { - _, err := pgpool.Exec(pgctx, `INSERT INTO accounts (username, password, display_name, created) VALUES ($1, $2, $3, $4) ON CONFLICT (username) DO NOTHING`, - acc.Username, acc.Password, acc.DisplayName, acc.Created) + // Read OTP secret + otp_secret := "" + secret, serr := os.ReadFile(configuration.Config.SecretsDir + "/" + acc.Username + "/secret") + if serr == nil { + otp_secret = string(secret[:]) + } + + // Read recovery codes + recoveryCodes := "" + codesB, rerr := os.ReadFile(configuration.Config.SecretsDir + "/" + acc.Username + "/codes") + if rerr == nil { + recoveryCodes = strings.ReplaceAll(strings.TrimSpace(string(codesB[:])), "\n", "|") + } + // remove acc.Username directory + os.RemoveAll(configuration.Config.SecretsDir + "/" + acc.Username) + + _, err := pgpool.Exec(pgctx, `INSERT INTO accounts (username, password, display_name, otp_secret, otp_recovery_codes, created) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (username) DO NOTHING`, + acc.Username, acc.Password, acc.DisplayName, otp_secret, recoveryCodes, acc.Created) if err != nil { logs.Logs.Println("[ERR][MIGRATION] error inserting user into Postgres: " + err.Error()) } @@ -222,8 +241,7 @@ func GetAccounts() ([]models.Account, error) { if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Created); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query row extraction" + err.Error()) } - accountStatus, _ := utils.GetUserStatus(accountRow.Username) - accountRow.TwoFA = accountStatus == "1" + accountRow.TwoFA = Is2FAEnabled(accountRow.Username) results = append(results, accountRow) } return results, err @@ -344,3 +362,64 @@ func ReportInstance() (*pgxpool.Pool, context.Context) { } return dbpool, dbctx } + +func GetUserOtpSecret(username string) string { + pgpool, pgctx := ReportInstance() + var otp_secret string + err := pgpool.QueryRow(pgctx, "SELECT otp_secret FROM accounts where username = $1 LIMIT 1", username).Scan(&otp_secret) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_USER_SECRET] error in query execution:" + err.Error()) + return "" + } + decrypted, err := utils.DecryptAESGCMFromString(otp_secret, []byte(configuration.Config.EncryptionKey)) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][DECRYPT_USER_SECRET] error in decryption:" + err.Error()) + return "" + } + return string(decrypted) +} + +func GetRecoveryCodes(username string) []string { + pgpool, pgctx := ReportInstance() + var otp_recovery_codes string + err := pgpool.QueryRow(pgctx, "SELECT otp_recovery_codes FROM accounts where username = $1 LIMIT 1", username).Scan(&otp_recovery_codes) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_RECOVERY_CODES] error in query execution:" + err.Error()) + return []string{} + } + return strings.Split(otp_recovery_codes, "|") +} + +func Is2FAEnabled(username string) bool { + pgpool, pgctx := ReportInstance() + var status sql.NullString + err := pgpool.QueryRow(pgctx, "SELECT otp_secret FROM accounts where username = $1 LIMIT 1", username).Scan(&status) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_2FA_STATUS] error in query execution:" + err.Error()) + } + return status.Valid && status.String != "" +} + +func SetUserOtpSecret(username string, secret string) error { + pgpool, pgctx := ReportInstance() + var otp_secret string + if len(secret) > 0 { + otp_secret, _ = utils.EncryptAESGCMToString([]byte(secret), []byte(configuration.Config.EncryptionKey)) + } else { + otp_secret = "" + } + _, err := pgpool.Exec(pgctx, "UPDATE accounts set otp_secret = $1 WHERE username = $2", otp_secret, username) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][SET_USER_OTP_SECRET] error in query execution:" + err.Error()) + } + return err +} + +func SetUserRecoveryCodes(username string, codes []string) error { + pgpool, pgctx := ReportInstance() + _, err := pgpool.Exec(pgctx, "UPDATE accounts set otp_recovery_codes = $1 WHERE username = $2", strings.Join(codes, "|"), username) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][SET_USER_RECOVERY_CODES] error in query execution:" + err.Error()) + } + return err +} diff --git a/api/utils/utils.go b/api/utils/utils.go index daf7f875..0e441891 100644 --- a/api/utils/utils.go +++ b/api/utils/utils.go @@ -12,10 +12,14 @@ package utils import ( "encoding/base64" "encoding/json" + "fmt" "net" - "os" "strconv" - "strings" + + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "io" "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/gin-gonic/gin" @@ -121,9 +125,65 @@ func Remove(a string, values []string) []string { return values } -func GetUserStatus(username string) (string, error) { - status, err := os.ReadFile(configuration.Config.SecretsDir + "/" + username + "/status") - statusS := strings.TrimSpace(string(status[:])) +// EncryptAESGCM encrypts plaintext using AES-GCM with the provided key. +func EncryptAESGCM(plaintext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// EncryptAESGCMToString encrypts plaintext and returns a base64 string for DB storage. +func EncryptAESGCMToString(plaintext, key []byte) (string, error) { + ciphertext, err := EncryptAESGCM(plaintext, key) + if err != nil { + fmt.Println("error", err) + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptAESGCMFromString decodes base64 string and decrypts using AES-GCM. +func DecryptAESGCMFromString(ciphertextB64 string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) + if err != nil { + return nil, err + } + plaintext, err := DecryptAESGCM(ciphertext, key) + if err == nil { + return plaintext, nil + } + return []byte(""), err +} - return statusS, err +// DecryptAESGCM decrypts ciphertext using AES-GCM with the provided key. +func DecryptAESGCM(ciphertext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(ciphertext) < gcm.NonceSize() { + return nil, io.ErrUnexpectedEOF + } + nonce := ciphertext[:gcm.NonceSize()] + ciphertext = ciphertext[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + return plaintext, nil } From 163aa5652412ce185e8eb7f8a39fd12e6ec83062 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 20 Jun 2025 16:20:36 +0200 Subject: [PATCH 05/39] feat(api): add /health endpoint The endpoint can be used to monitor the service --- api/README.md | 19 +++++++++++++++++++ api/main.go | 5 +++++ api/main_test.go | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/api/README.md b/api/README.md index 007527b1..965ed115 100644 --- a/api/README.md +++ b/api/README.md @@ -68,6 +68,25 @@ CGO_ENABLED=0 go build ### Auth +- `GET /health` + + REQ + + ```json + Content-Type: application/json + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "status": "ok" + } + ``` + - `POST /login` REQ diff --git a/api/main.go b/api/main.go index 10311de9..8c7d5ebf 100644 --- a/api/main.go +++ b/api/main.go @@ -91,6 +91,11 @@ func setup() *gin.Engine { api.POST("/login", middleware.InstanceJWT().LoginHandler) api.POST("/logout", middleware.InstanceJWT().LogoutHandler) + // define healthcheck endpoint + api.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + // 2FA APIs api.POST("/2fa/otp-verify", methods.OTPVerify) diff --git a/api/main_test.go b/api/main_test.go index 06ed3d6d..215ea53d 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -86,6 +86,25 @@ func TestAESGCMToString(t *testing.T) { } } +// TestHealthEndpoint tests the /health endpoint. +func TestHealthEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + router := setupRouter() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "ok" { + t.Errorf("expected status 'ok', got %v", resp["status"]) + } +} + func TestMainEndpoints(t *testing.T) { // Tests assume to run on a clean database, otherwise 2FA tests will fail gin.SetMode(gin.TestMode) From daf85d5b24bc4b3ece7771993377a5d6444396b3 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Mon, 23 Jun 2025 10:15:07 +0200 Subject: [PATCH 06/39] feat(api): support multiple listen addresses Allow to receive traffic directly inside the VPN This change is part of a refactor to improve security. --- api/README.md | 3 ++- api/configuration/configuration.go | 6 ++--- api/main.go | 13 +++++++++-- api/main_test.go | 36 +++++++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/api/README.md b/api/README.md index 965ed115..68a2c217 100644 --- a/api/README.md +++ b/api/README.md @@ -29,7 +29,8 @@ CGO_ENABLED=0 go build **Optional** -- `LISTEN_ADDRESS`: listend address of server - _default_: `127.0.0.1:5000` +- `LISTEN_ADDRESS`: a comma-separated list of listen addresses for the server. Each entry is in the form `
:` - _default_: `127.0.0.1:5000` + Example: `127.0.0.1:5000,192.168.100.1:5000` - `OVPN_DIR`: openvpn configuration directory - _default_: `/etc/openvpn` - `OVPN_NETWORK`: openvpn network address - _default_: `172.21.0.0` diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 66ffae07..189e1144 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -29,7 +29,7 @@ type Configuration struct { OpenVPNPKIDir string `json:"openvpn_pki_dir"` OpenVPNMGMTSock string `json:"openvpn_mgmt_sock"` - ListenAddress string `json:"listen_address"` + ListenAddress []string `json:"listen_address"` AdminUsername string `json:"admin_username"` AdminPassword string `json:"admin_password"` @@ -79,9 +79,9 @@ var Config = Configuration{} func Init() { // read configuration from ENV if os.Getenv("LISTEN_ADDRESS") != "" { - Config.ListenAddress = os.Getenv("LISTEN_ADDRESS") + Config.ListenAddress = strings.Split(os.Getenv("LISTEN_ADDRESS"), ",") } else { - Config.ListenAddress = "127.0.0.1:5000" + Config.ListenAddress = []string{"127.0.0.1:5000"} } if os.Getenv("ADMIN_USERNAME") != "" { diff --git a/api/main.go b/api/main.go index 8c7d5ebf..5018bb81 100644 --- a/api/main.go +++ b/api/main.go @@ -169,6 +169,15 @@ func setup() *gin.Engine { func main() { router := setup() - // run server - router.Run(configuration.Config.ListenAddress) + // Listen on multiple addresses + for _, addr := range configuration.Config.ListenAddress { + go func(a string) { + if err := router.Run(a); err != nil { + logs.Logs.Println("[CRITICAL][API] Server failed to start on address: " + a) + } + }(addr) + } + + // Prevent main from exiting + select {} } diff --git a/api/main_test.go b/api/main_test.go index 215ea53d..81dd61c0 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -19,6 +19,40 @@ import ( "github.com/stretchr/testify/assert" ) +func TestMultipleListenAddresses(t *testing.T) { + gin.SetMode(gin.TestMode) + router := setupRouter() + + // Start two servers on different listeners + if len(configuration.Config.ListenAddress) < 2 { + t.Fatalf("expected at least 2 listen addresses, got %d", len(configuration.Config.ListenAddress)) + } + + servers := make([]*httptest.Server, 0, len(configuration.Config.ListenAddress)) + for range configuration.Config.ListenAddress { + // Use httptest.Server to simulate listening on multiple addresses + ts := httptest.NewServer(router) + servers = append(servers, ts) + } + defer func() { + for _, ts := range servers { + ts.Close() + } + }() + + // Test /health endpoint on all servers + for i, ts := range servers { + resp, err := http.Get(ts.URL + "/health") + if err != nil { + t.Fatalf("server %d: failed to GET /health: %v", i, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("server %d: expected status 200, got %d", i, resp.StatusCode) + } + } +} + // TestAESGCMEncryption tests AES-GCM encryption and decryption. func TestAESGCMEncryption(t *testing.T) { key := []byte("12345678901234567890123456789012") // AES-256, 32 byte @@ -354,7 +388,7 @@ func TestMainEndpoints(t *testing.T) { } func setupRouter() *gin.Engine { - os.Setenv("LISTEN_ADDRESS", "0.0.0.0:8000") + os.Setenv("LISTEN_ADDRESS", "0.0.0.0:8000,127.0.0.1:5000") os.Setenv("ADMIN_USERNAME", "admin") // default password is "password" os.Setenv("ADMIN_PASSWORD", "admin") From d4e66561a0a1e9037fd9dd72b17630f1f9f18117 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 20 Jun 2025 16:42:32 +0200 Subject: [PATCH 07/39] feat(api): add trusted IP middleware --- api/README.md | 6 ++ api/configuration/configuration.go | 42 +++++++++++ api/main.go | 3 + api/main_test.go | 114 ++++++++++++++++++++--------- api/middleware/middleware.go | 36 +++++++++ 5 files changed, 166 insertions(+), 35 deletions(-) diff --git a/api/README.md b/api/README.md index 68a2c217..3596e199 100644 --- a/api/README.md +++ b/api/README.md @@ -65,6 +65,12 @@ CGO_ENABLED=0 go build - `ENCRYPTION_KEY`: key to encrypt/decrypt sensitive data, it must be 32 bytes long +- `TRUSTED_IPS`: list of trusted IPs/CIDRs to access the API - _default_: ``. + If the list is empty, all IPs are allowed. +- `TRUSTED_IP_EXCLUDE_PATHS`: list of paths to exclude from trusted IPs check - _default_: `` + This is useful to allow access to the API from the units without authentication. + Usually the only path that could be excluded is `/units/register`. + ## APIs ### Auth diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 189e1144..c5b8d4c2 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -10,7 +10,9 @@ package configuration import ( + "net" "os" + "slices" "strings" "github.com/NethServer/nethsecurity-controller/api/logs" @@ -72,6 +74,9 @@ type Configuration struct { RetentionDays string `json:"retention_days"` EncryptionKey string `json:"encryption_key"` + + TrustedIPs []net.IPNet `json:"trusted_ips"` + TrustedIPExcludePaths []string `json:"trusted_ip_exclude_paths"` } var Config = Configuration{} @@ -314,4 +319,41 @@ func Init() { logs.Logs.Println("[CRITICAL][ENV] ENCRYPTION_KEY variable is empty") os.Exit(1) } + + if os.Getenv("TRUSTED_IPS") != "" { + var cidrs []string + for _, entry := range strings.Split(os.Getenv("TRUSTED_IPS"), ",") { + entry = strings.TrimSpace(entry) + if entry != "" { + cidrs = append(cidrs, entry) + } + } + // Remove duplicates and sort + slices.Sort(cidrs) + cidrs = slices.Compact(cidrs) + for _, cidr := range cidrs { + if !strings.Contains(cidr, "/") { + cidr += "/32" + } + _, network, err := net.ParseCIDR(cidr) + if err == nil { + Config.TrustedIPs = append(Config.TrustedIPs, *network) + } + } + } else { + Config.TrustedIPs = []net.IPNet{} + } + + if os.Getenv("TRUSTED_IP_EXCLUDE_PATHS") != "" { + for _, entry := range strings.Split(os.Getenv("TRUSTED_IP_EXCLUDE_PATHS"), ",") { + entry = strings.TrimSpace(entry) + if entry != "" { + Config.TrustedIPExcludePaths = append(Config.TrustedIPExcludePaths, entry) + } + } + slices.Sort(Config.TrustedIPExcludePaths) + Config.TrustedIPExcludePaths = slices.Compact(Config.TrustedIPExcludePaths) + } else { + Config.TrustedIPExcludePaths = []string{} + } } diff --git a/api/main.go b/api/main.go index 5018bb81..503bd58a 100644 --- a/api/main.go +++ b/api/main.go @@ -72,6 +72,9 @@ func setup() *gin.Engine { // init routers router := gin.Default() + // add trusted IP middleware globally + router.Use(middleware.TrustedIPMiddleware()) + // add default compression router.Use(gzip.Gzip(gzip.DefaultCompression)) diff --git a/api/main_test.go b/api/main_test.go index 81dd61c0..c5d5f2fe 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -19,39 +19,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestMultipleListenAddresses(t *testing.T) { - gin.SetMode(gin.TestMode) - router := setupRouter() - - // Start two servers on different listeners - if len(configuration.Config.ListenAddress) < 2 { - t.Fatalf("expected at least 2 listen addresses, got %d", len(configuration.Config.ListenAddress)) - } - - servers := make([]*httptest.Server, 0, len(configuration.Config.ListenAddress)) - for range configuration.Config.ListenAddress { - // Use httptest.Server to simulate listening on multiple addresses - ts := httptest.NewServer(router) - servers = append(servers, ts) - } - defer func() { - for _, ts := range servers { - ts.Close() - } - }() - - // Test /health endpoint on all servers - for i, ts := range servers { - resp, err := http.Get(ts.URL + "/health") - if err != nil { - t.Fatalf("server %d: failed to GET /health: %v", i, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("server %d: expected status 200, got %d", i, resp.StatusCode) - } - } -} +var router *gin.Engine // TestAESGCMEncryption tests AES-GCM encryption and decryption. func TestAESGCMEncryption(t *testing.T) { @@ -120,10 +88,44 @@ func TestAESGCMToString(t *testing.T) { } } +func TestMultipleListenAddresses(t *testing.T) { + gin.SetMode(gin.TestMode) + router = setupRouter() + + // Start two servers on different listeners + if len(configuration.Config.ListenAddress) < 2 { + t.Fatalf("expected at least 2 listen addresses, got %d", len(configuration.Config.ListenAddress)) + } + + servers := make([]*httptest.Server, 0, len(configuration.Config.ListenAddress)) + for range configuration.Config.ListenAddress { + // Use httptest.Server to simulate listening on multiple addresses + ts := httptest.NewServer(router) + servers = append(servers, ts) + } + defer func() { + for _, ts := range servers { + ts.Close() + } + }() + + // Test /health endpoint on all servers + for i, ts := range servers { + resp, err := http.Get(ts.URL + "/health") + if err != nil { + t.Fatalf("server %d: failed to GET /health: %v", i, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("server %d: expected status 200, got %d", i, resp.StatusCode) + } + } +} + // TestHealthEndpoint tests the /health endpoint. func TestHealthEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) - router := setupRouter() + router = setupRouter() w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/health", nil) router.ServeHTTP(w, req) @@ -142,7 +144,7 @@ func TestHealthEndpoint(t *testing.T) { func TestMainEndpoints(t *testing.T) { // Tests assume to run on a clean database, otherwise 2FA tests will fail gin.SetMode(gin.TestMode) - router := setupRouter() + router = setupRouter() var token string t.Run("TestLoginEndpoint", func(t *testing.T) { @@ -387,7 +389,49 @@ func TestMainEndpoints(t *testing.T) { }) } +// TestTrustedIPMiddleware tests the TrustedIPMiddleware for allowed and denied +func TestTrustedIPMiddleware(t *testing.T) { + os.Setenv("TRUSTED_IPS", "127.0.0.1,192.168.1.0/24") + os.Setenv("TRUSTED_IP_EXCLUDE_PATHS", "/units/register") + gin.SetMode(gin.TestMode) + restricted_router := setup() + + // Allowed: 127.0.0.1 + req := httptest.NewRequest("GET", "/health", nil) + req.RemoteAddr = "127.0.0.1:12345" + w := httptest.NewRecorder() + restricted_router.ServeHTTP(w, req) + if w.Code != 200 { + t.Errorf("expected 200 for allowed IP, got %d", w.Code) + } + + // Denied: 10.0.0.1 + req = httptest.NewRequest("GET", "/health", nil) + req.RemoteAddr = "10.0.0.1:12345" + w = httptest.NewRecorder() + restricted_router.ServeHTTP(w, req) + if w.Code != 403 { + t.Errorf("expected 403 for denied IP, got %d", w.Code) + } + + // Allowed: /units/register endpoint should be unrestricted + body := `{"unit_id": "11", "username": "aa", "unit_name": "bbb", "password": "ccc"}` + req = httptest.NewRequest("POST", "/units/register", bytes.NewBuffer([]byte(body))) + req.RemoteAddr = "10.0.0.1:12345" + req.Header.Set("Content-Type", "application/json") + req.Header.Set("RegistrationToken", "1234") + w = httptest.NewRecorder() + restricted_router.ServeHTTP(w, req) + if w.Code != 200 { + t.Errorf("expected 200 for /units/register endpoint, got %d", w.Code) + } +} + func setupRouter() *gin.Engine { + // Singleton + if router != nil { + return router + } os.Setenv("LISTEN_ADDRESS", "0.0.0.0:8000,127.0.0.1:5000") os.Setenv("ADMIN_USERNAME", "admin") // default password is "password" diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 3bd5e1a5..111e3ff7 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -13,6 +13,7 @@ import ( "bytes" "encoding/json" "io" + "net" "net/http" "os" "strings" @@ -303,3 +304,38 @@ func BasicAuth() gin.HandlerFunc { c.Next() } } + +// TrustedIPMiddleware restricts access to requests from trusted IPs/CIDRs defined in TRUSTED_IPS env var. +func TrustedIPMiddleware() gin.HandlerFunc { + if len(configuration.Config.TrustedIPs) == 0 { + // No restriction + return func(c *gin.Context) { + c.Next() + } + } + // Return the middleware handler + return func(c *gin.Context) { + // If path is inside TrustedIPExcludePaths, just skip the middleware + if len(configuration.Config.TrustedIPExcludePaths) > 0 { + for _, path := range configuration.Config.TrustedIPExcludePaths { + if strings.HasPrefix(c.Request.URL.Path, path) { + c.Next() + return + } + } + } + clientIP := c.ClientIP() + ip := net.ParseIP(clientIP) + if ip == nil { + c.AbortWithStatusJSON(403, gin.H{"error": "forbidden: invalid client IP"}) + return + } + for _, network := range configuration.Config.TrustedIPs { + if network.Contains(ip) { + c.Next() + return + } + } + c.AbortWithStatusJSON(403, gin.H{"error": "forbidden: IP not allowed"}) + } +} From bf7e0520f7dfa86256004ace05fd020b79318b8a Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 24 Jun 2025 12:08:55 +0200 Subject: [PATCH 08/39] chore(api): test add and get info --- api/main_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/api/main_test.go b/api/main_test.go index c5d5f2fe..23492d4d 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -13,6 +13,7 @@ import ( "crypto/rand" "github.com/NethServer/nethsecurity-controller/api/configuration" + "github.com/NethServer/nethsecurity-controller/api/models" "github.com/NethServer/nethsecurity-controller/api/utils" "github.com/gin-gonic/gin" "github.com/pquerna/otp/totp" @@ -427,6 +428,86 @@ func TestTrustedIPMiddleware(t *testing.T) { } } +func TestAddInfoAndGetRemoteInfo(t *testing.T) { + gin.SetMode(gin.TestMode) + router = setupRouter() + + // Prepare: create a credentials file for the unit + unitID := "457c2b94-13be-48c9-b4aa-c7d677c85ee8" + if _, err := os.Stat(configuration.Config.CredentialsDir); os.IsNotExist(err) { + os.MkdirAll(configuration.Config.CredentialsDir, 0755) + } + if _, err := os.Stat(configuration.Config.OpenVPNCCDDir); os.IsNotExist(err) { + os.MkdirAll(configuration.Config.OpenVPNCCDDir, 0755) + } + if _, err := os.Stat(configuration.Config.OpenVPNPKIDir); os.IsNotExist(err) { + os.MkdirAll(configuration.Config.OpenVPNPKIDir, 0755) + } + if _, err := os.Stat(configuration.Config.OpenVPNStatusDir); os.IsNotExist(err) { + os.MkdirAll(configuration.Config.OpenVPNStatusDir, 0755) + } + + // Create fake credentials, ccd and cr files, otherwise GetUnit will fail + creds := map[string]string{"username": "testuser", "password": "testpass"} + credsBytes, _ := json.Marshal(creds) + werr := os.WriteFile(configuration.Config.CredentialsDir+"/"+unitID, credsBytes, 0644) + assert.NoError(t, werr, "failed to write credentials file") + conf := "ifconfig-push 10.10.10.2 255.255.255.0 \n" + werr = os.WriteFile(configuration.Config.OpenVPNCCDDir+"/"+unitID, []byte(conf), 0644) + assert.NoError(t, werr, "failed to write ccd file") + if _, err := os.Stat(configuration.Config.OpenVPNPKIDir + "/issued/" + unitID + ".crt"); os.IsNotExist(err) { + if _, err := os.Create(configuration.Config.OpenVPNPKIDir + "/issued/" + unitID + ".crt"); err != nil { + t.Fatalf("failed to create certificate file: %v", err) + } + } + + // AddInfo: POST /ingest/info (simulate BasicAuth middleware) + w := httptest.NewRecorder() + info := models.UnitInfo{ + UnitName: "my-test-unit", + Version: "1.0.0", + VersionUpdate: "1.0.1", + ScheduledUpdate: 0, + SubscriptionType: "test-subscription", + SystemID: "test-system-id", + SSHPort: 22, + FQDN: "test.example.com", + APIVersion: "v1", + } + infoBytes, _ := json.Marshal(info) + req := httptest.NewRequest("POST", "/ingest/info", bytes.NewBuffer(infoBytes)) + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(unitID, "1234") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "AddInfo should return 200 OK") + + w = httptest.NewRecorder() + var jsonResponse map[string]interface{} + body := `{"username": "admin", "password": "admin"}` + req, _ = http.NewRequest("POST", "/login", bytes.NewBuffer([]byte(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + json.NewDecoder(w.Body).Decode(&jsonResponse) + token := jsonResponse["token"].(string) + + // Call /units/:unit_id to retrieve unit info + req = httptest.NewRequest("GET", "/units/"+unitID, nil) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + json.NewDecoder(w.Body).Decode(&jsonResponse) + infoResp := jsonResponse["data"].(map[string]interface{})["info"].(map[string]interface{}) + assert.Equal(t, info.UnitName, infoResp["unit_name"]) + assert.Equal(t, info.Version, infoResp["version"]) + assert.Equal(t, info.VersionUpdate, infoResp["version_update"]) + assert.Equal(t, float64(info.ScheduledUpdate), infoResp["scheduled_update"]) + assert.Equal(t, info.SubscriptionType, infoResp["subscription_type"]) + assert.Equal(t, info.SystemID, infoResp["system_id"]) + assert.Equal(t, float64(info.SSHPort), infoResp["ssh_port"]) + assert.Equal(t, info.FQDN, infoResp["fqdn"]) + assert.Equal(t, info.APIVersion, infoResp["api_version"]) +} + func setupRouter() *gin.Engine { // Singleton if router != nil { From dcc44028e9e0abd41d3f124257413bd3287def55 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 24 Jun 2025 14:49:23 +0200 Subject: [PATCH 09/39] feat(api)!: save unit info inside db --- api/README.md | 2 +- api/configuration/configuration.go | 8 +-- api/main_test.go | 4 ++ api/methods/unit.go | 44 ++++++------ api/storage/report_schema.sql.tmpl | 4 +- api/storage/storage.go | 105 +++++++++++++++++++++++++++++ api/storage/upgrade_schema.sql | 34 ++++++++++ 7 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 api/storage/upgrade_schema.sql diff --git a/api/README.md b/api/README.md index 3596e199..3b6e474a 100644 --- a/api/README.md +++ b/api/README.md @@ -304,7 +304,7 @@ CGO_ENABLED=0 go build } ``` - The API saves unit information in `OVPN_S_DIR` with `.info` extension. This is useful for retrieving new information of the unit without waiting for cron to store it. + The backend stores data inside the database. This is useful for retrieving new information of the unit without waiting for cron to store it. - `GET /units//token` diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index c5b8d4c2..c59def35 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -25,7 +25,7 @@ type Configuration struct { OpenVPNNetmask string `json:"openvpn_netmask"` OpenVPNUDPPort string `json:"openvpn_udp_port"` - OpenVPNStatusDir string `json:"openvpn_status_dir"` + OpenVPNStatusDir string `json:"openvpn_status_dir"` // Deprecated: it can be removed in the future OpenVPNCCDDir string `json:"openvpn_ccd_dir"` OpenVPNProxyDir string `json:"openvpn_proxy_dir"` OpenVPNPKIDir string `json:"openvpn_pki_dir"` @@ -173,11 +173,7 @@ func Init() { Config.OpenVPNUDPPort = "1194" } - if os.Getenv("OVPN_S_DIR") != "" { - Config.OpenVPNStatusDir = os.Getenv("OVPN_S_DIR") - } else { - Config.OpenVPNStatusDir = Config.OpenVPNDir + "/status" - } + Config.OpenVPNStatusDir = Config.OpenVPNDir + "/status" if os.Getenv("OVPN_C_DIR") != "" { Config.OpenVPNCCDDir = os.Getenv("OVPN_C_DIR") } else { diff --git a/api/main_test.go b/api/main_test.go index 23492d4d..5b0a8cdc 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -14,6 +14,7 @@ import ( "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/NethServer/nethsecurity-controller/api/models" + "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" "github.com/gin-gonic/gin" "github.com/pquerna/otp/totp" @@ -460,6 +461,9 @@ func TestAddInfoAndGetRemoteInfo(t *testing.T) { t.Fatalf("failed to create certificate file: %v", err) } } + // Manually add to the database: we can't call /units POST endpoint because + // it requires the presence of easyrsa binary and configuration files + storage.AddUnit(unitID) // AddInfo: POST /ingest/info (simulate BasicAuth middleware) w := httptest.NewRecorder() diff --git a/api/methods/unit.go b/api/methods/unit.go index b4825170..9adaec98 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -25,6 +25,7 @@ import ( "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/NethServer/nethsecurity-controller/api/models" "github.com/NethServer/nethsecurity-controller/api/socket" + "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" "github.com/fatih/structs" @@ -155,16 +156,16 @@ func AddInfo(c *gin.Context) { return } - jsonInfo, _ := json.Marshal(jsonRequest) - err := os.WriteFile(configuration.Config.OpenVPNStatusDir+"/"+unitId+".info", jsonInfo, 0644) + _, err := json.Marshal(jsonRequest) if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, - Message: "can't write unit info for: " + unitId, + Message: "can't marshal unit info for: " + unitId, Data: err.Error(), })) return } + storage.SetUnitInfo(unitId, jsonRequest) // return 200 OK c.JSON(http.StatusOK, structs.Map(response.StatusOK{ @@ -305,6 +306,17 @@ func AddUnit(c *gin.Context) { return } + // create record inside units table + errCreate := storage.AddUnit(jsonRequest.UnitId) + if errCreate != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "cannot store unit record inside database for: " + jsonRequest.UnitId, + Data: errCreate.Error(), + })) + return + } + // return 200 OK with data c.JSON(http.StatusOK, structs.Map(response.StatusOK{ Code: 200, @@ -713,12 +725,8 @@ func GetRemoteInfo(unitId string) (models.UnitInfo, error) { unitInfo.Data.ScheduledUpdate = systemUpdateInfo.Data.ScheduledAt unitInfo.Data.VersionUpdate = systemUpdateInfo.Data.LastVersion - // write json to file - jsonInfo, _ := json.Marshal(unitInfo.Data) - errWrite := os.WriteFile(configuration.Config.OpenVPNStatusDir+"/"+unitId+".info", jsonInfo, 0644) - if errWrite != nil { - return models.UnitInfo{}, errors.New("error writing info file") - } + // write json to database + storage.SetUnitInfo(unitId, unitInfo.Data) return unitInfo.Data, nil } @@ -734,7 +742,8 @@ func getUnitInfo(unitId string) (gin.H, error) { result := parseUnitFile(unitId, unitFile) // add info from unit - remote_info := getRemoteInfo(unitId) + remote_info := storage.GetUnitInfo(unitId) + if remote_info != nil { result["info"] = remote_info } else { @@ -771,21 +780,6 @@ func getVPNInfo(unitId string) gin.H { } } -func getRemoteInfo(unitId string) gin.H { - // read unit file - statusFile, err := os.ReadFile(configuration.Config.OpenVPNStatusDir + "/" + unitId + ".info") - if err != nil { - return nil - } - - // convert timestamp to int - var result map[string]interface{} - _ = json.Unmarshal(statusFile, &result) - - // return vpn details - return result -} - func readUnitFile(unitId string) ([]byte, error) { // read unit file unitFile, err := os.ReadFile(configuration.Config.OpenVPNCCDDir + "/" + unitId) diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index e9c7d7bd..e1bcd83f 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -22,7 +22,9 @@ CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS units ( uuid UUID PRIMARY KEY, name TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + info JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS openvpn_config ( diff --git a/api/storage/storage.go b/api/storage/storage.go index 18620171..5ff863b4 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -14,6 +14,7 @@ import ( "context" "database/sql" _ "embed" + "encoding/json" "html/template" "os" "strings" @@ -35,6 +36,9 @@ var err error //go:embed report_schema.sql.tmpl var reportSchemaSQL string +//go:embed upgrade_schema.sql +var upgradeSchemaSQL string + //go:embed grafana_user.sql.tmpl var grafanaUserSQL string @@ -47,6 +51,9 @@ func Init() *pgxpool.Pool { // Migrate users from SQLite to Postgres if needed MigrateUsersFromSqliteToPostgres() + // Migrate unit info from file to Postgres if needed + MigrateUnitInfoFromFileToPostgres() + // Initialize PostgreSQL connection dbctx = context.Background() dbpool, err = pgxpool.New(dbctx, configuration.Config.ReportDbUri) @@ -323,6 +330,14 @@ func loadReportSchema(*pgxpool.Pool, context.Context) bool { logs.Logs.Println("[ERR][STORAGE] error in storage file schema init:" + errExecute.Error()) return false } + + // execute upgrade schema + logs.Logs.Println("[INFO][STORAGE] upgrading report schema") + _, errExecute = dbpool.Exec(dbctx, upgradeSchemaSQL) + if errExecute != nil { + logs.Logs.Println("[ERR][STORAGE] error in storage file schema upgrade:" + errExecute.Error()) + return false + } return true } @@ -423,3 +438,93 @@ func SetUserRecoveryCodes(username string, codes []string) error { } return err } + +func AddUnit(uuid string) error { + pgpool, pgctx := ReportInstance() + // Try to insert the unit; if it already exists, return an error + _, err := pgpool.Exec(pgctx, ` + INSERT INTO units (uuid, created_at, updated_at) + VALUES ($1, NOW(), NOW()) + ON CONFLICT (uuid) DO NOTHING + `, uuid) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][ADD_UNIT] error in query execution:" + err.Error()) + } + return err +} + +func SetUnitInfo(uuid string, info models.UnitInfo) error { + pgpool, pgctx := ReportInstance() + // Try to update the unit; if no rows are affected, return an error + res, err := pgpool.Exec(pgctx, ` + UPDATE units SET name = $2, info = $3::jsonb, updated_at = NOW() + WHERE uuid = $1 + `, uuid, info.UnitName, info) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][SET_UNIT_INFO] error in query execution:" + err.Error()) + return err + } + if res.RowsAffected() == 0 { + logs.Logs.Println("[WARN][STORAGE][SET_UNIT_INFO] unit with uuid " + uuid + " does not exist") + } + return err +} + +func GetUnitInfo(uuid string) map[string]interface{} { + pgpool, pgctx := ReportInstance() + var infoStr string + err := pgpool.QueryRow(pgctx, ` + SELECT info::text FROM units WHERE uuid = $1 + `, uuid).Scan(&infoStr) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT_INFO] error in query execution:" + err.Error()) + return nil + } + var info map[string]interface{} + if err := json.Unmarshal([]byte(infoStr), &info); err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT_INFO] error unmarshalling info:" + err.Error()) + return nil + } + return info +} + +func MigrateUnitInfoFromFileToPostgres() { + // Search for all *.info file inside Config.OpenVPNStatusDir + // If the dir does not exists, just return + if _, err := os.Stat(configuration.Config.OpenVPNStatusDir); os.IsNotExist(err) { + return + } + files, err := os.ReadDir(configuration.Config.OpenVPNStatusDir) + if err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error reading OpenVPN status directory: " + err.Error()) + return + } + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".info") { + filePath := configuration.Config.OpenVPNStatusDir + "/" + file.Name() + // read file, parse as JSON and then call AddUnit + data, err := os.ReadFile(filePath) + if err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error reading file:", filePath, err.Error()) + continue + } + var info models.UnitInfo + if err := json.Unmarshal(data, &info); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error parsing JSON in file:", filePath, err.Error()) + continue + } + uuid := strings.TrimSuffix(file.Name(), ".info") + // ignore the error + _ = AddUnit(uuid) + if err := SetUnitInfo(uuid, info); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error setting unit info for", uuid, ":", err.Error()) + } + // remove the file + if err := os.Remove(filePath); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error removing file:", filePath, err.Error()) + } else { + logs.Logs.Println("[INFO][MIGRATION] removed file:", filePath) + } + } + } +} diff --git a/api/storage/upgrade_schema.sql b/api/storage/upgrade_schema.sql new file mode 100644 index 00000000..6ffe6d73 --- /dev/null +++ b/api/storage/upgrade_schema.sql @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: GPL-2.0-only + * + * author: Giacomo Sanchietti + */ + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name='units' + AND column_name='info' + ) THEN + ALTER TABLE units ADD COLUMN info JSONB; + END IF; +END; +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name='units' + AND column_name='updated_at' + ) THEN + ALTER TABLE units ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + END IF; +END; +$$; \ No newline at end of file From 070bde2dc51b5b8249711363b81cc3571efb3280 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 24 Jun 2025 15:32:22 +0200 Subject: [PATCH 10/39] feat(api): add /auth API This API che be used to receive forwarded authentication from other middlewares. It sets a X-Auth-User header --- api/README.md | 26 +++++++++++++++++++++++ api/main.go | 8 ++++++- api/main_test.go | 22 +++++++++++++++++++ api/middleware/middleware.go | 41 +++++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index 3b6e474a..384602a5 100644 --- a/api/README.md +++ b/api/README.md @@ -687,6 +687,32 @@ CGO_ENABLED=0 go build } ``` +## Basic authentication API + +- GET `/auth` + + This endpoint is used to check if the user is authenticated. It returns a 200 status code if the user is authenticated, otherwise it returns a 401 status code. + It can be used by external applications to check if the user is authenticated without needing to handle JWT tokens. + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + X-Auth-User: + + { + "authentication": "ok" + } + ``` + ### Defaults - `GET /defaults` diff --git a/api/main.go b/api/main.go index 503bd58a..90864306 100644 --- a/api/main.go +++ b/api/main.go @@ -154,10 +154,16 @@ func setup() *gin.Engine { } // Ingest APIs: receive data from firewalls - authorized := router.Group("/ingest", middleware.BasicAuth()) + authorized := router.Group("/ingest", middleware.BasicUnitAuth()) authorized.POST("/info", methods.AddInfo) authorized.POST("/:firewall_api", methods.HandelMonitoring) + // Forwarded authentication middleware + forwarded := router.Group("/auth", middleware.BasicUserAuth()) + forwarded.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"auth": "ok"}) + }) + // handle missing endpoint router.NoRoute(func(c *gin.Context) { c.JSON(http.StatusNotFound, structs.Map(response.StatusNotFound{ diff --git a/api/main_test.go b/api/main_test.go index 5b0a8cdc..16e29dae 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -512,6 +512,28 @@ func TestAddInfoAndGetRemoteInfo(t *testing.T) { assert.Equal(t, info.APIVersion, infoResp["api_version"]) } +func TestForwardedAuthMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + router = setupRouter() + + // Test with valid credentials + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/auth", nil) + req.SetBasicAuth("admin", "admin") // Use BasicAuth for testing + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + // Check if X-Auth-User header is set + authUser := w.Header().Get("X-Auth-User") + assert.Equal(t, "admin", authUser, "X-Auth-User header should be set to 'admin'") + + // Test with invalid credentials + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/auth", nil) + req.SetBasicAuth("admin", "wrongpassword") // Use BasicAuth for testing + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) +} + func setupRouter() *gin.Engine { // Singleton if router != nil { diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 111e3ff7..9e82072a 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -266,7 +266,7 @@ func InitJWT() *jwt.GinJWTMiddleware { return authMiddleware } -func BasicAuth() gin.HandlerFunc { +func BasicUnitAuth() gin.HandlerFunc { return func(c *gin.Context) { uuid, token, _ := c.Request.BasicAuth() if uuid == "" || token == "" { @@ -305,6 +305,45 @@ func BasicAuth() gin.HandlerFunc { } } +func BasicUserAuth() gin.HandlerFunc { + return func(c *gin.Context) { + username, password, _ := c.Request.BasicAuth() + + if username == "" || password == "" { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusUnauthorized{ + Code: 400, + Message: "missing username or password", + Data: nil, + })) + c.Abort() + return + } + + // read user password hash + passwordHash := storage.GetPassword(username) + + // check password and username + valid := utils.CheckPasswordHash(password, passwordHash) + isAdmin, _ := storage.IsAdmin(username) + + if !valid || !isAdmin { + c.JSON(http.StatusUnauthorized, structs.Map(response.StatusUnauthorized{ + Code: 401, + Message: "invalid username or password", + Data: nil, + })) + logs.Logs.Println("[INFO][AUTH] user " + username + " authentication failed") + c.Abort() + return + } + + // Just return success + logs.Logs.Println("[INFO][AUTH] user " + username + " authenticated successfully") + c.Header("X-Auth-User", username) + c.Next() + } +} + // TrustedIPMiddleware restricts access to requests from trusted IPs/CIDRs defined in TRUSTED_IPS env var. func TrustedIPMiddleware() gin.HandlerFunc { if len(configuration.Config.TrustedIPs) == 0 { From 8fc4cf39d67192c4cc75ba8dffdbb877fae630b4 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 24 Jun 2025 16:55:12 +0200 Subject: [PATCH 11/39] feat(api): add /platform endpoint Can be used to expose NS8 info inside the controller UI --- api/README.md | 33 ++++++++++++++++++++++++++ api/configuration/configuration.go | 15 ++++++++++++ api/main.go | 3 +++ api/main_test.go | 38 ++++++++++++++++++++++++++++++ api/methods/defaults.go | 9 +++++++ api/models/platform.go | 18 ++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 api/models/platform.go diff --git a/api/README.md b/api/README.md index 384602a5..f88d0358 100644 --- a/api/README.md +++ b/api/README.md @@ -71,6 +71,9 @@ CGO_ENABLED=0 go build This is useful to allow access to the API from the units without authentication. Usually the only path that could be excluded is `/units/register`. +- `PLATFORM_INFO`: a JSON string with platform information, used to store the controller version and other information. It can be left empty. + Example: `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "metrics_retention_days":30, "logs_retention_days":90}` + ## APIs ### Auth @@ -687,6 +690,36 @@ CGO_ENABLED=0 go build } ``` +- `GET /platform` + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "code": 200, + "data": { + "vpn_port": "1194", + "vpn_network": "172.21.0.0/16", + "controller_version": "1.2.3", + "nethserver_version": "8-23.05.3-ns.1.0.1", + "nethserver_system_id": "XXXXXXXX-XXXX", + "metrics_retention_days": 60, + "logs_retention_days": 30 + }, + "message": "success" + } + ``` + ## Basic authentication API - GET `/auth` diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index c59def35..8f2bb636 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -10,12 +10,14 @@ package configuration import ( + "encoding/json" "net" "os" "slices" "strings" "github.com/NethServer/nethsecurity-controller/api/logs" + "github.com/NethServer/nethsecurity-controller/api/models" "github.com/Showmax/go-fqdn" ) @@ -77,6 +79,8 @@ type Configuration struct { TrustedIPs []net.IPNet `json:"trusted_ips"` TrustedIPExcludePaths []string `json:"trusted_ip_exclude_paths"` + + PlatformInfo models.PlatformInfo `json:"platform_info"` } var Config = Configuration{} @@ -352,4 +356,15 @@ func Init() { } else { Config.TrustedIPExcludePaths = []string{} } + + if os.Getenv("PLATFORM_INFO") != "" { + var platformInfo models.PlatformInfo + err := json.Unmarshal([]byte(os.Getenv("PLATFORM_INFO")), &platformInfo) + if err != nil { + logs.Logs.Println("[WARNING][ENV] PLATFORM_INFO variable is not valid JSON:", err) + } + Config.PlatformInfo = platformInfo + } else { + Config.PlatformInfo = models.PlatformInfo{} + } } diff --git a/api/main.go b/api/main.go index 90864306..108d9052 100644 --- a/api/main.go +++ b/api/main.go @@ -151,6 +151,9 @@ func setup() *gin.Engine { units.POST("", methods.AddUnit) units.DELETE("/:unit_id", methods.DeleteUnit) } + + // platforms APIs + api.GET("/platform", methods.GetPlatformInfo) } // Ingest APIs: receive data from firewalls diff --git a/api/main_test.go b/api/main_test.go index 16e29dae..93ab682e 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -534,6 +534,43 @@ func TestForwardedAuthMiddleware(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) } +func TestGetPlatformInfo(t *testing.T) { + router = setupRouter() + + // Step 1: Login and get token + loginBody := []byte(`{"username":"admin","password":"admin"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(loginBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var loginResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&loginResp) + token, ok := loginResp["token"].(string) + assert.True(t, ok) + assert.NotEmpty(t, token) + + // Step 2: Call GET /platform with token + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/platform", nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Step 3: Check response + var resp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&resp) + assert.Equal(t, float64(200), resp["code"]) + assert.Equal(t, "success", resp["message"]) + data, ok := resp["data"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "1194", data["vpn_port"]) + assert.Equal(t, "192.168.100.0/24", data["vpn_network"]) + assert.Equal(t, "1.0.0", data["controller_version"]) + assert.Equal(t, float64(30), data["metrics_retention_days"]) + assert.Equal(t, float64(90), data["logs_retention_days"]) +} + func setupRouter() *gin.Engine { // Singleton if router != nil { @@ -559,6 +596,7 @@ func setupRouter() *gin.Engine { os.Setenv("ISSUER_2FA", "test") os.Setenv("SECRETS_DIR", "./secrets") os.Setenv("ENCRYPTION_KEY", "12345678901234567890123456789012") + os.Setenv("PLATFORM_INFO", `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "metrics_retention_days":30, "logs_retention_days":90}`) // create directory configuration directory if _, err := os.Stat(os.Getenv("DATA_DIR")); os.IsNotExist(err) { diff --git a/api/methods/defaults.go b/api/methods/defaults.go index f93ba18a..feebd7f4 100644 --- a/api/methods/defaults.go +++ b/api/methods/defaults.go @@ -32,3 +32,12 @@ func GetDefaults(c *gin.Context) { }, })) } + +func GetPlatformInfo(c *gin.Context) { + // read and return platform info + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "success", + Data: structs.Map(configuration.Config.PlatformInfo), + })) +} diff --git a/api/models/platform.go b/api/models/platform.go new file mode 100644 index 00000000..3d660d95 --- /dev/null +++ b/api/models/platform.go @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: GPL-2.0-only + * + * author: Giacomo Sanchietti + */ + +package models + +type PlatformInfo struct { + VpnPort string `json:"vpn_port" structs:"vpn_port"` + VpnNetwork string `json:"vpn_network" structs:"vpn_network"` + ControllerVersion string `json:"controller_version" structs:"controller_version"` + MetricsRetentionDays int `json:"metrics_retention_days" structs:"metrics_retention_days"` + LogsRetentionDays int `json:"logs_retention_days" structs:"logs_retention_days"` +} From 6ab4a7441e3927c077fddffab60955a481f33054 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 25 Jun 2025 07:48:03 +0200 Subject: [PATCH 12/39] chore(api): cleanup deps with go mod tidy --- api/go.mod | 5 +- api/go.sum | 187 ++--------------------------------------------------- 2 files changed, 6 insertions(+), 186 deletions(-) diff --git a/api/go.mod b/api/go.mod index b5e7c2c0..99d88e05 100644 --- a/api/go.mod +++ b/api/go.mod @@ -9,7 +9,6 @@ require ( github.com/NethServer/nethsecurity-api v0.0.0-20241002122635-8157091120e5 github.com/Showmax/go-fqdn v1.0.0 github.com/appleboy/gin-jwt/v2 v2.10.3 - github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 github.com/fatih/structs v1.1.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/gzip v1.2.3 @@ -19,13 +18,14 @@ require ( github.com/mattn/go-sqlite3 v1.14.28 github.com/nqd/flat v0.2.0 github.com/oschwald/geoip2-golang v1.11.0 + github.com/pquerna/otp v1.5.0 github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.39.0 ) require ( - github.com/bytedance/sonic v1.13.3 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -49,7 +49,6 @@ require ( github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/pquerna/otp v1.5.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect diff --git a/api/go.sum b/api/go.sum index f4dd95a3..c3377329 100644 --- a/api/go.sum +++ b/api/go.sum @@ -2,8 +2,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/Jeffail/gabs/v2 v2.7.0 h1:Y2edYaTcE8ZpRsR2AtmPu5xQdFDIthFG0jYhu5PY8kg= github.com/Jeffail/gabs/v2 v2.7.0/go.mod h1:dp5ocw1FvBBQYssgHsG7I1WYsiLRtkUaB1FEtSwvNUw= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/NethServer/nethsecurity-api v0.0.0-20230609091000-bf319035cafc h1:0PruTZEu2eCjJVNrVzH7zlu1mB0NQoOvNWc4nSx0v8A= -github.com/NethServer/nethsecurity-api v0.0.0-20230609091000-bf319035cafc/go.mod h1:BS9ciZ+WG8SM5zqo0iTaKn7E7bpDnSh1XKSPc6Hy89Q= github.com/NethServer/nethsecurity-api v0.0.0-20241002122635-8157091120e5 h1:yBttZobscZce41QM5zluFn6Yees2jEnp67WwdHT71Tw= github.com/NethServer/nethsecurity-api v0.0.0-20241002122635-8157091120e5/go.mod h1:J7avk5KJCxBC1xKnE7dhbi0Kh9v8UNO+KFUNFg6dG8Q= github.com/NethServer/ns8-core/core/api-server v0.0.0-20230511093202-c2f91171c039/go.mod h1:pMPGsATL4dPazf1RPv8EqJuvPJ/+hVEZIGzUYzHzXwY= @@ -14,10 +12,7 @@ github.com/Showmax/go-fqdn v1.0.0 h1:0rG5IbmVliNT5O19Mfuvna9LL7zlHyRfsSvBPZmF9tM github.com/Showmax/go-fqdn v1.0.0/go.mod h1:SfrFBzmDCtCGrnHhoDjuvFnKsWjEQX/Q9ARZvOrJAko= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/appleboy/gin-jwt/v2 v2.6.4/go.mod h1:CZpq1cRw+kqi0+yD2CwVw7VGXrrx4AqBdeZnwxVmoAs= -github.com/appleboy/gin-jwt/v2 v2.9.1 h1:l29et8iLW6omcHltsOP6LLk4s3v4g2FbFs0koxGWVZs= github.com/appleboy/gin-jwt/v2 v2.9.1/go.mod h1:jwcPZJ92uoC9nOUTOKWoN/f6JZOgMSKlFSHw5/FrRUk= -github.com/appleboy/gin-jwt/v2 v2.10.1 h1:I68+9qGsgHDx8omd65MKhYXF7Qz5LtdFFTsB/kSU4z0= -github.com/appleboy/gin-jwt/v2 v2.10.1/go.mod h1:xuzn4aNUwqwR3+j+jbL6MhryiRKinUL1SJ7WUfB33vU= github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc= github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8= github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= @@ -26,39 +21,16 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8 github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= -github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= -github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= -github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= -github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q= -github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I= -github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ= -github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= -github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= -github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= -github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o= -github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= -github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= -github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -67,51 +39,27 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 h1:AqeKSZIG/NIC75MNQlPy/LM3LxfpLwahICJBHwSMFNc= github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3/go.mod h1:hEfFauPHz7+NnjR/yHJGhrKo1Za+zStgwUETx3yzqgY= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= -github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk= -github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g= github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs= -github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg= -github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro= -github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns= -github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4= -github.com/gin-contrib/cors v1.7.4 h1:/fC6/wk7rCRtqKqki8lLr2Xq+hnV49aXDLIuSek9g4k= -github.com/gin-contrib/cors v1.7.4/go.mod h1:vGc/APSgLMlQfEJV5NAzkrAHb0C8DetL3K6QZuvGii0= -github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk= -github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w= github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc= -github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/gzip v1.2.0 h1:JzN6DT3/xYL5zAdviN1ORNzKeklrwafXCIDKIR+qmUA= -github.com/gin-contrib/gzip v1.2.0/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s= -github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI= -github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s= github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U= github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c= github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= -github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs= @@ -122,10 +70,6 @@ github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwv github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= @@ -163,31 +107,16 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= -github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= -github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= -github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= -github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-redis/redis/v8 v8.8.0/go.mod h1:F7resOH5Kdug49Otu24RjHWwgK7u9AmtqWMnCV1iP5Y= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= -github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -204,8 +133,9 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -215,20 +145,10 @@ github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= -github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= -github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= -github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 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.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -239,10 +159,6 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= @@ -258,8 +174,6 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -275,17 +189,9 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= -github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -313,10 +219,6 @@ github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnY github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= -github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= -github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -340,7 +242,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -350,11 +251,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E= @@ -362,8 +258,9 @@ github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05 github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y= github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc= github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= -github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -381,10 +278,6 @@ github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2t github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= @@ -398,16 +291,6 @@ go.opentelemetry.io/otel/metric v0.19.0/go.mod h1:8f9fglJPRnXuskQmKpnad31lcLJ2Vm go.opentelemetry.io/otel/oteltest v0.19.0/go.mod h1:tI4yxwh8U21v7JD6R3BcA/2+RBoTKFexE/PJ/nSO7IA= go.opentelemetry.io/otel/trace v0.19.0/go.mod h1:4IXiNextNOpPnRlI4ryK69mn5iC84bjBWZQA5DXz/qg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= -golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= -golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA= -golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= -golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= -golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -419,18 +302,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -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/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -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/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -457,16 +328,6 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -474,16 +335,6 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ 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/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -510,23 +361,12 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -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/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -544,16 +384,6 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -567,7 +397,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc 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-20191204190536-9bdfabe68543/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= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -578,14 +407,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= -google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From f99540361ae851c3761dededf58eb7030b7044c7 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 25 Jun 2025 13:03:16 +0200 Subject: [PATCH 13/39] feat(api): improve register endpoint The register API now returns also the following new fields: - api_port, like '20001' - vpn_address, like '192.168.100.1' These fields can be used from unit ns-plug to push data directly inside the VPN. This change is part of a refactor to improve security. --- api/README.md | 6 ++++-- api/methods/unit.go | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index f88d0358..d2316533 100644 --- a/api/README.md +++ b/api/README.md @@ -377,7 +377,7 @@ CGO_ENABLED=0 go build "password": "Nethesis,1234", "version": "8-23.05.2-ns.0.0.2-beta2-37-g6e74afc", "subscription_type": "enterprise", - "system_id": "XXXXXXXX-XXXX", + "system_id": "XXXXXXXX-XXXX" } ``` @@ -396,7 +396,9 @@ CGO_ENABLED=0 go build "key": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----", "port": "1194", "promtail_address": "172.21.0.1", - "promtail_port": "5151" + "promtail_port": "5151", + "api_port": "20001", + "vpn_address": "192.168.0.1" }, "message": "unit registered successfully" } diff --git a/api/methods/unit.go b/api/methods/unit.go index 9adaec98..81ed49eb 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -423,6 +423,13 @@ func RegisterUnit(c *gin.Context) { return } + // extract API port from listen address + addressParts := strings.Split(configuration.Config.ListenAddress[0], ":") + apiPort := addressParts[len(addressParts)-1] + // calculate server address from OpenVPNNetwork + openvpnNetwork := strings.TrimSuffix(configuration.Config.OpenVPNNetwork, ".0") + vpnAddress := openvpnNetwork + ".1" + // compose config config := gin.H{ "host": configuration.Config.FQDN, @@ -432,6 +439,8 @@ func RegisterUnit(c *gin.Context) { "key": keyS, "promtail_address": configuration.Config.PromtailAddress, "promtail_port": configuration.Config.PromtailPort, + "api_port": apiPort, + "vpn_address": vpnAddress, } // read credentials from request From 4401afa60a104de8c1ae1287fe68f9924b478141 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 26 Jun 2025 12:10:25 +0200 Subject: [PATCH 14/39] feat(api)!: add unit groups management This change is part of a refactor to improve security. --- api/README.md | 176 +++++++++++++++++++- api/go.mod | 1 + api/go.sum | 1 + api/main.go | 10 ++ api/main_test.go | 228 ++++++++++++++++++++++++- api/methods/account.go | 27 ++- api/methods/unit.go | 259 ++++++++++++++++++++++++++++- api/models/account.go | 5 +- api/models/unit.go | 9 + api/storage/report_schema.sql.tmpl | 13 +- api/storage/storage.go | 189 +++++++++++++++++++-- 11 files changed, 879 insertions(+), 39 deletions(-) diff --git a/api/README.md b/api/README.md index d2316533..3927c89d 100644 --- a/api/README.md +++ b/api/README.md @@ -74,6 +74,32 @@ CGO_ENABLED=0 go build - `PLATFORM_INFO`: a JSON string with platform information, used to store the controller version and other information. It can be left empty. Example: `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "metrics_retention_days":30, "logs_retention_days":90}` +## User and units authorizations + +A unit is a NethSecurity firewall that is connected to the controller. +Units are identified by a unique ID and are stored in the database. VPN info of the unit are stored in a file in the `OVPN_DIR` directory. + +A group of units is a collection of units that can be managed together. +Units can be added to a group via the API. The group is identified by a unique ID and is stored in the database. + +A user is an account that can access the API and the UI. +User accounts are stored in the database and can be managed via the API. +By default, a user can access all units but this can be restricted by assigning the user to a group of units. + +There is also a special `admin` user with id `1` that can: +- create, modify and delete user accounts +- create, modify and delete units +- create, modify and delete units groups +- assign a user to one or more groups of units + +The following rules apply: + +- a user can be assigned to one or more groups of units, if the user is not assigned to any group, the user can access all units +- a non existing-unit cannot be added to a group +- a unit group that is associated to a user account can't be deleted +- a non-existing unit group cannot be assigned to a user account +- when a unit is deleted from the database, it is removed from all groups + ## APIs ### Auth @@ -439,6 +465,151 @@ CGO_ENABLED=0 go build } ``` +### Unit Groups + +- `GET /unit_groups` + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "code": 200, + "data": { + "unit_groups": [ + { + "id": 1, + "name": "Group 1", + "description": "This is a test group", + "units": ["unit_id_1", "unit_id_2"], + "created": "2024-03-14T09:37:28+01:00", + "updated": "2024-03-14T10:00:00+01:00" + } + ... + ] + }, + "message": "unit groups listed successfully" + } + ``` + +- `GET /unit_groups/` + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "code": 200, + "data": { + "unit_group": { + "id": 1, + "name": "Group 1", + "description": "This is a test group", + "units": ["unit_id_1", "unit_id_2"], + "created": "2024-03-14T09:37:28+01:00", + "updated": "2024-03-14T10:00:00+01:00" + } + }, + "message": "success" + } + ``` + +- `POST /unit_groups` + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + + { + "name": "Group 1", + "descrption": "This is a test group", + "units": ["unit_id_1", "unit_id_2"] + } + ``` + + RES + + ```json + HTTP/1.1 201 OK + Content-Type: application/json; charset=utf-8 + + { + "code": 201, + "data": {"id": 1}, + "message": "success" + } + ``` + +- `PUT /unit_groups/` + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + + { + "name": "Group 1 updated", + "description": "This is an updated test group", + "units": ["unit_id_1", "unit_id_3"] + } + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "code": 200, + "data": null, + "message": "success" + } + ``` + +- `DELETE /unit_groups/` + + REQ + + ```json + Content-Type: application/json + Authorization: Bearer + ``` + + RES + + ```json + HTTP/1.1 200 OK + Content-Type: application/json; charset=utf-8 + + { + "code": 200, + "data": "", + "message": "success" + } + ``` + ### Accounts - `GET /accounts` @@ -536,7 +707,7 @@ CGO_ENABLED=0 go build { "code": 201, - "data": null, + "data": {"id": 5}, "message": "success" } ``` @@ -551,7 +722,8 @@ CGO_ENABLED=0 go build { "password": "Nethesis,4321", - "display_name": "Test 5" + "display_name": "Test 5", + "unit_groups": [1, 2] } ``` diff --git a/api/go.mod b/api/go.mod index 99d88e05..9fb93862 100644 --- a/api/go.mod +++ b/api/go.mod @@ -14,6 +14,7 @@ require ( github.com/gin-contrib/gzip v1.2.3 github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/google/uuid v1.2.0 github.com/jackc/pgx/v5 v5.7.5 github.com/mattn/go-sqlite3 v1.14.28 github.com/nqd/flat v0.2.0 diff --git a/api/go.sum b/api/go.sum index c3377329..05ab62fa 100644 --- a/api/go.sum +++ b/api/go.sum @@ -137,6 +137,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= diff --git a/api/main.go b/api/main.go index 108d9052..b6bc623d 100644 --- a/api/main.go +++ b/api/main.go @@ -152,6 +152,16 @@ func setup() *gin.Engine { units.DELETE("/:unit_id", methods.DeleteUnit) } + // unit_groups APIs + unitGroups := api.Group("/unit_groups") + { + unitGroups.GET("", methods.ListUnitGroups) + unitGroups.GET("/:group_id", methods.GetUnitGroup) + unitGroups.POST("", methods.AddUnitGroup) + unitGroups.PUT("/:group_id", methods.UpdateUnitGroup) + unitGroups.DELETE("/:group_id", methods.DeleteUnitGroup) + } + // platforms APIs api.GET("/platform", methods.GetPlatformInfo) } diff --git a/api/main_test.go b/api/main_test.go index 93ab682e..31d9de53 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -11,12 +11,14 @@ import ( "time" "crypto/rand" + mathrand "math/rand" "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/NethServer/nethsecurity-controller/api/models" "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" ) @@ -198,7 +200,6 @@ func TestMainEndpoints(t *testing.T) { var jsonResponse map[string]interface{} json.NewDecoder(w.Body).Decode(&jsonResponse) data := jsonResponse["data"].(map[string]interface{}) - assert.Equal(t, int(data["total"].(float64)), 1) assert.Equal(t, data["accounts"].([]interface{})[0].(map[string]interface{})["username"], "admin") assert.Equal(t, data["accounts"].([]interface{})[0].(map[string]interface{})["display_name"], "Administrator") assert.Equal(t, data["accounts"].([]interface{})[0].(map[string]interface{})["two_fa"], false) @@ -213,6 +214,10 @@ func TestMainEndpoints(t *testing.T) { addReq.Header.Set("Authorization", "Bearer "+token) router.ServeHTTP(w, addReq) assert.Equal(t, http.StatusCreated, w.Code) + var addResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&addResp) + id := fmt.Sprintf("%v", addResp["data"].(map[string]interface{})["id"]) + assert.NotEmpty(t, id) // Get accounts to find the new account's ID w = httptest.NewRecorder() getReq, _ := http.NewRequest("GET", "/accounts", nil) @@ -230,7 +235,7 @@ func TestMainEndpoints(t *testing.T) { } assert.NotEmpty(t, testAccountID) // Update account display name - updateBody := `{"display_name": "Updated User"}` + updateBody := `{"display_name": "Updated User", "unit_groups": []}` updateReq, _ := http.NewRequest("PUT", "/accounts/"+testAccountID, bytes.NewBuffer([]byte(updateBody))) updateReq.Header.Set("Content-Type", "application/json") updateReq.Header.Set("Authorization", "Bearer "+token) @@ -429,12 +434,10 @@ func TestTrustedIPMiddleware(t *testing.T) { } } -func TestAddInfoAndGetRemoteInfo(t *testing.T) { - gin.SetMode(gin.TestMode) - router = setupRouter() +func addUnit(t *testing.T) string { + // Generate a UUID v4 and convert it to string using the uuid package + unitID := uuid.New().String() - // Prepare: create a credentials file for the unit - unitID := "457c2b94-13be-48c9-b4aa-c7d677c85ee8" if _, err := os.Stat(configuration.Config.CredentialsDir); os.IsNotExist(err) { os.MkdirAll(configuration.Config.CredentialsDir, 0755) } @@ -465,6 +468,16 @@ func TestAddInfoAndGetRemoteInfo(t *testing.T) { // it requires the presence of easyrsa binary and configuration files storage.AddUnit(unitID) + return unitID +} + +func TestAddInfoAndGetRemoteInfo(t *testing.T) { + gin.SetMode(gin.TestMode) + router = setupRouter() + + // Simulate an add unit + unitID := addUnit(t) + // AddInfo: POST /ingest/info (simulate BasicAuth middleware) w := httptest.NewRecorder() info := models.UnitInfo{ @@ -571,6 +584,205 @@ func TestGetPlatformInfo(t *testing.T) { assert.Equal(t, float64(90), data["logs_retention_days"]) } +func TestUnitGroupsAPI(t *testing.T) { + router = setupRouter() + unitId_1 := addUnit(t) + unitId_2 := addUnit(t) + randCounter := fmt.Sprintf("%d", mathrand.New(mathrand.NewSource(time.Now().UnixNano())).Intn(10000)) + + // Login to get token + w := httptest.NewRecorder() + loginBody := []byte(`{"username":"admin","password":"admin"}`) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(loginBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var loginResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&loginResp) + token, ok := loginResp["token"].(string) + assert.True(t, ok) + assert.NotEmpty(t, token) + + // Create an empty unit group + w = httptest.NewRecorder() + // generate a random group name composed by testgroups + random number from 1 to 1000 + groupName := fmt.Sprintf("testgroups%s", randCounter) + groupBody := []byte(`{"name":"` + groupName + `","description":"desc"}`) + req, _ = http.NewRequest("POST", "/unit_groups", bytes.NewBuffer(groupBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var groupResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&groupResp) + groupData := groupResp["data"].(map[string]interface{}) + groupID := fmt.Sprintf("%v", groupData["id"]) + assert.NotEmpty(t, groupID) + + // Update the group with units + w = httptest.NewRecorder() + updateBody := []byte(`{"name":"` + groupName + `","description":"desc", "units":["` + unitId_1 + `", "` + unitId_2 + `"]}`) + req, _ = http.NewRequest("PUT", "/unit_groups/"+groupID, bytes.NewBuffer([]byte(updateBody))) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // List unit groups + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/unit_groups", nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Get the created unit group + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/unit_groups/"+groupID, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var getGroupResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&getGroupResp) + groupData = getGroupResp["data"].(map[string]interface{}) + assert.Equal(t, groupName, groupData["name"]) + assert.Equal(t, "desc", groupData["description"]) + units := groupData["units"].([]interface{}) + assert.Len(t, units, 2) + assert.Contains(t, units, unitId_1) + assert.Contains(t, units, unitId_2) + + // Update the unit group + w = httptest.NewRecorder() + updateBody = []byte(`{"name":"updatedgroup` + randCounter + `","description":"updated desc", "units":["` + unitId_1 + `", "` + unitId_2 + `"]}}`) + req, _ = http.NewRequest("PUT", "/unit_groups/"+groupID, bytes.NewBuffer(updateBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + // Get the updated unit group and check the new name and description + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/unit_groups/"+groupID, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var updatedGroupResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&updatedGroupResp) + updatedGroupData := updatedGroupResp["data"].(map[string]interface{}) + assert.Equal(t, "updatedgroup"+randCounter, updatedGroupData["name"]) + assert.Equal(t, "updated desc", updatedGroupData["description"]) + + // Try to update the group with a non-existing unit, expect failure (400) + w = httptest.NewRecorder() + nonExistingUnitID := uuid.New().String() + updateBody = []byte(`{"name":"` + groupName + `","description":"desc", "units":["` + unitId_1 + `", "` + nonExistingUnitID + `"]}`) + req, _ = http.NewRequest("PUT", "/unit_groups/"+groupID, bytes.NewBuffer(updateBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code, "should fail when adding non-existing unit to group") + + // Add limited user account + w = httptest.NewRecorder() + limitedUserName := fmt.Sprintf("limited%s", randCounter) + addBody := `{"username": "` + limitedUserName + `", "password": "limited", "display_name": "Limited user"}` + addReq, _ := http.NewRequest("POST", "/accounts", bytes.NewBuffer([]byte(addBody))) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, addReq) + assert.Equal(t, http.StatusCreated, w.Code) + var addUserResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&addUserResp) + testAccountID := fmt.Sprintf("%v", addUserResp["data"].(map[string]interface{})["id"]) + assert.NotEmpty(t, testAccountID) + + // Try to add a non-existing group ID to the user account, expect failure + w = httptest.NewRecorder() + nonExistingGroupID := "9999999" + addUserBody := []byte(`{"username":"` + limitedUserName + `","unit_groups":[` + nonExistingGroupID + `]}`) + req, _ = http.NewRequest("PUT", "/accounts/"+testAccountID, bytes.NewBuffer(addUserBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code, "should fail when adding non-existing group ID") + + // Add unit group to user account + w = httptest.NewRecorder() + addUserBody = []byte(`{"username":"` + limitedUserName + `","unit_groups":[` + groupID + `]}`) + req, _ = http.NewRequest("PUT", "/accounts/"+testAccountID, bytes.NewBuffer(addUserBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // Delete the unit group: should fail because it is associated with an account + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", "/unit_groups/"+groupID, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusOK, w.Code, "should not allow deleting a group associated with an account") + assert.True(t, w.Code == http.StatusBadRequest, "expected 400 when deleting a group in use") + + // Remove the group from the user account's unit_groups + w = httptest.NewRecorder() + removeGroupBody := []byte(`{"username":"` + limitedUserName + `","unit_groups":[]}`) + req, _ = http.NewRequest("PUT", "/accounts/"+testAccountID, bytes.NewBuffer(removeGroupBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // Now delete the unit group again, should succeed + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", "/unit_groups/"+groupID, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "should allow deleting a group not associated with any account") + + // Get the account again and check that unit_groups does not contain groupID + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/accounts/"+testAccountID, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var getAccountAfterDeleteResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&getAccountAfterDeleteResp) + accountAfterDeleteData := getAccountAfterDeleteResp["data"].(map[string]interface{})["account"].(map[string]interface{}) + unitGroups := accountAfterDeleteData["unit_groups"].([]interface{}) + for _, v := range unitGroups { + assert.NotEqual(t, groupID, fmt.Sprintf("%.0f", v), "unit_groups should not contain deleted groupID") + } + + // Create a new group and add unitId_1 to it + w = httptest.NewRecorder() + groupName2 := fmt.Sprintf("group2_%s", randCounter) + groupBody2 := []byte(`{"name":"` + groupName2 + `","description":"desc2", "units":["` + unitId_1 + `"]}`) + req, _ = http.NewRequest("POST", "/unit_groups", bytes.NewBuffer(groupBody2)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var group2Resp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&group2Resp) + group2Data := group2Resp["data"].(map[string]interface{}) + group2ID := fmt.Sprintf("%v", group2Data["id"]) + assert.NotEmpty(t, group2ID) + + // Get the group and check that unitId_1 is present + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/unit_groups/"+group2ID, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var getGroup3Resp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&getGroup3Resp) + group2Data = getGroup3Resp["data"].(map[string]interface{}) + units2 := group2Data["units"].([]interface{}) + assert.Len(t, units2, 1, "group2 should contain exactly one unit") + assert.Equal(t, unitId_1, fmt.Sprintf("%v", units2[0]), "unitId_1 should be present in group2") + + // DELETE "/units/"+unitId_1 can't be tested because it requires easy-rsa binary and configuration file +} + func setupRouter() *gin.Engine { // Singleton if router != nil { @@ -596,7 +808,7 @@ func setupRouter() *gin.Engine { os.Setenv("ISSUER_2FA", "test") os.Setenv("SECRETS_DIR", "./secrets") os.Setenv("ENCRYPTION_KEY", "12345678901234567890123456789012") - os.Setenv("PLATFORM_INFO", `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "metrics_retention_days":30, "logs_retention_days":90}`) + os.Setenv("PLATFORM_INFO", `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "nethserver_version":"1.6.0", "nethserver_system_id":"1234567890", "metrics_retention_days":30, "logs_retention_days":90}`) // create directory configuration directory if _, err := os.Stat(os.Getenv("DATA_DIR")); os.IsNotExist(err) { diff --git a/api/methods/account.go b/api/methods/account.go index 1a68d94b..31f0f40d 100644 --- a/api/methods/account.go +++ b/api/methods/account.go @@ -135,7 +135,7 @@ func AddAccount(c *gin.Context) { // create account json.Created = time.Now() - err := storage.AddAccount(json) + id, err := storage.AddAccount(json) // check results if err != nil { @@ -151,7 +151,7 @@ func AddAccount(c *gin.Context) { c.JSON(http.StatusCreated, structs.Map(response.StatusCreated{ Code: 201, Message: "success", - Data: nil, + Data: gin.H{"id": id}, })) } @@ -180,7 +180,28 @@ func UpdateAccount(c *gin.Context) { // update account err := storage.UpdateAccount(accountID, json) - // check results + // check if all unit_groups exist + for _, groupID := range json.UnitGroups { + exists, err := storage.UnitGroupExists(groupID) + if err != nil { + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, + Message: "error checking group existence", + Data: err.Error(), + })) + return + } + if !exists { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "unit group does not exist", + Data: gin.H{"unit_group": groupID}, + })) + return + } + } + + // check for groupid_not_found error if err != nil { c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ Code: 500, diff --git a/api/methods/unit.go b/api/methods/unit.go index 81ed49eb..c6f68ab5 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -27,6 +27,7 @@ import ( "github.com/NethServer/nethsecurity-controller/api/socket" "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" + jwt "github.com/appleboy/gin-jwt/v2" "github.com/fatih/structs" "github.com/gin-gonic/gin" @@ -508,8 +509,8 @@ func DeleteUnit(c *gin.Context) { "EASYRSA_PKI="+configuration.Config.OpenVPNPKIDir, ) if err := cmdRevoke.Run(); err != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, Message: "cannot revoke certificate for: " + unitId, Data: err.Error(), })) @@ -524,8 +525,8 @@ func DeleteUnit(c *gin.Context) { "EASYRSA_CRL_DAYS=3650", ) if err := cmdGen.Run(); err != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, Message: "cannot renew certificate revocation list (CLR)", Data: err.Error(), })) @@ -536,8 +537,8 @@ func DeleteUnit(c *gin.Context) { if _, err := os.Stat(configuration.Config.OpenVPNCCDDir + "/" + unitId); err == nil { errDeleteAuth := os.Remove(configuration.Config.OpenVPNCCDDir + "/" + unitId) if errDeleteAuth != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 403, + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, Message: "error in deletion auth file for: " + unitId, Data: errDeleteAuth.Error(), })) @@ -549,8 +550,8 @@ func DeleteUnit(c *gin.Context) { if _, err := os.Stat(configuration.Config.OpenVPNProxyDir + "/" + unitId + ".yaml"); err == nil { errDeleteProxy := os.Remove(configuration.Config.OpenVPNProxyDir + "/" + unitId + ".yaml") if errDeleteProxy != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 403, + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, Message: "error in deletion proxy file for: " + unitId, Data: errDeleteProxy.Error(), })) @@ -811,3 +812,245 @@ func parseUnitFile(unitId string, unitFile []byte) gin.H { return result } + +func AddUnitGroup(c *gin.Context) { + isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "admin privileges required", + })) + return + } + + var req models.UnitGroup + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "request fields malformed", + Data: err.Error(), + })) + return + } + + id, err := storage.AddUnitGroup(req) + if err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "cannot add unit group", + Data: err.Error(), + })) + return + } + + c.JSON(http.StatusCreated, structs.Map(response.StatusCreated{ + Code: 201, + Message: "unit group added successfully", + Data: gin.H{"id": id}, + })) +} + +func UpdateUnitGroup(c *gin.Context) { + isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "admin privileges required", + })) + return + } + + groupId := c.Param("group_id") + if groupId == "" { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id is required", + })) + return + } + groupIntId, err := strconv.Atoi(groupId) + if err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id must be an integer", + Data: err.Error(), + })) + return + } + + var req models.UnitGroup + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "request fields malformed", + Data: err.Error(), + })) + return + } + + for _, unit := range req.Units { + exists, err := storage.UnitExists(unit) + if err != nil { + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, + Message: "error checking unit existence", + Data: err.Error(), + })) + return + } + if !exists { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "unit does not exist", + Data: unit, + })) + return + } + } + + if err := storage.UpdateUnitGroup(groupIntId, req); err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "cannot edit unit group", + Data: err.Error(), + })) + return + } + + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit group edited successfully", + })) +} + +func DeleteUnitGroup(c *gin.Context) { + isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "admin privileges required", + })) + return + } + + groupId := c.Param("group_id") + if groupId == "" { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id is required", + })) + return + } + groupIdInt, err := strconv.Atoi(groupId) + if err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id must be an integer", + Data: err.Error(), + })) + return + } + + // check if the unit group is used + used, err := storage.IsUnitGroupUsed(groupIdInt) + if err != nil { + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, + Message: "error checking if unit group is used", + Data: err.Error(), + })) + return + } + if used { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "unit group is used and cannot be deleted", + })) + return + } + + if err := storage.DeleteUnitGroup(groupIdInt); err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "cannot delete unit group", + Data: err.Error(), + })) + return + } + + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit group deleted successfully", + })) +} + +func ListUnitGroups(c *gin.Context) { + isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "admin privileges required", + })) + return + } + + groups, err := storage.ListUnitGroups() + if err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "cannot list unit groups", + Data: err.Error(), + })) + return + } + + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit groups listed successfully", + Data: groups, + })) +} + +func GetUnitGroup(c *gin.Context) { + isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "admin privileges required", + })) + return + } + + groupId := c.Param("group_id") + if groupId == "" { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id is required", + })) + return + } + groupIdInt, err := strconv.Atoi(groupId) + if err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id must be an integer", + Data: err.Error(), + })) + return + } + group, err := storage.GetUnitGroup(groupIdInt) + if err != nil { + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "cannot get unit group", + Data: err.Error(), + })) + return + } + + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit group retrieved successfully", + Data: group, + })) +} diff --git a/api/models/account.go b/api/models/account.go index a0214bf5..c0746156 100644 --- a/api/models/account.go +++ b/api/models/account.go @@ -18,13 +18,16 @@ type Account struct { Username string `json:"username" structs:"username" binding:"required,excludesall= "` Password string `json:"password" structs:"password" db:"-" binding:"required"` DisplayName string `json:"display_name" structs:"display_name"` - Created time.Time `json:"created" structs:"created"` + UnitGroups []int `json:"unit_groups" structs:"unit_groups"` + Created time.Time `json:"created" structs:"created_at"` + Updated time.Time `json:"updated" structs:"updated_at"` TwoFA bool `json:"two_fa" structs:"two_fa"` } type AccountUpdate struct { Password string `json:"password" structs:"password"` DisplayName string `json:"display_name" structs:"display_name"` + UnitGroups []int `json:"unit_groups" structs:"unit_groups" binding:"required"` } type PasswordChange struct { diff --git a/api/models/unit.go b/api/models/unit.go index 04e856c6..7f5c9163 100644 --- a/api/models/unit.go +++ b/api/models/unit.go @@ -53,3 +53,12 @@ type CheckSystemUpdate struct { ScheduledAt int `json:"scheduledAt"` CurrentVersion string `json:"currentVersion"` } + +type UnitGroup struct { + ID int `json:"id" structs:"id"` + Name string `json:"name" structs:"name"` + Description string `json:"description" structs:"description"` + Units []string `json:"units" structs:"units"` + CreatedAt time.Time `json:"created_at" structs:"created_at"` + UpdatedAt time.Time `json:"updated_at" structs:"updated_at"` +} diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index e1bcd83f..76d45da8 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -16,7 +16,18 @@ CREATE TABLE IF NOT EXISTS accounts ( display_name TEXT, otp_secret TEXT, otp_recovery_codes TEXT, - created TIMESTAMP NOT NULL + unit_groups int[], + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS unit_groups ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + units uuid[], + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS units ( diff --git a/api/storage/storage.go b/api/storage/storage.go index 5ff863b4..2c701b6d 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -15,8 +15,10 @@ import ( "database/sql" _ "embed" "encoding/json" + "fmt" "html/template" "os" + "strconv" "strings" "time" @@ -82,7 +84,7 @@ func Init() *pgxpool.Pool { DisplayName: "Administrator", Created: time.Now(), } - _ = AddAccount(admin) + _, _ = AddAccount(admin) } return dbpool @@ -105,7 +107,7 @@ func MigrateUsersFromSqliteToPostgres() { defer sqliteDB.Close() // 3. Check if SQLite has users - rows, err := sqliteDB.Query("SELECT id, username, password, display_name, created FROM accounts") + rows, err := sqliteDB.Query("SELECT id, username, password, display_name, created_at FROM accounts") if err != nil { logs.Logs.Println("[ERR][MIGRATION] cannot query SQLite accounts: " + err.Error()) return @@ -146,7 +148,9 @@ func MigrateUsersFromSqliteToPostgres() { display_name TEXT, otp_secret TEXT, otp_recovery_codes TEXT, - created TIMESTAMP NOT NULL + unit_groups []int, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP), )`) if err != nil { logs.Logs.Println("[ERR][MIGRATION] error creating accounts table in Postgres: " + err.Error()) @@ -171,7 +175,7 @@ func MigrateUsersFromSqliteToPostgres() { // remove acc.Username directory os.RemoveAll(configuration.Config.SecretsDir + "/" + acc.Username) - _, err := pgpool.Exec(pgctx, `INSERT INTO accounts (username, password, display_name, otp_secret, otp_recovery_codes, created) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (username) DO NOTHING`, + _, err := pgpool.Exec(pgctx, `INSERT INTO accounts (username, password, display_name, otp_secret, otp_recovery_codes, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (username) DO NOTHING`, acc.Username, acc.Password, acc.DisplayName, otp_secret, recoveryCodes, acc.Created) if err != nil { logs.Logs.Println("[ERR][MIGRATION] error inserting user into Postgres: " + err.Error()) @@ -187,30 +191,50 @@ func MigrateUsersFromSqliteToPostgres() { } // Refactored user functions to use PostgreSQL -func AddAccount(account models.Account) error { +func AddAccount(account models.Account) (int, error) { pgpool, pgctx := ReportInstance() - _, err := pgpool.Exec(pgctx, - "INSERT INTO accounts (username, password, display_name, created) VALUES ($1, $2, $3, $4)", + var id int + err := pgpool.QueryRow(pgctx, + "INSERT INTO accounts (username, password, display_name, unit_groups, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id", account.Username, utils.HashPassword(account.Password), account.DisplayName, + account.UnitGroups, account.Created, - ) + ).Scan(&id) if err != nil { logs.Logs.Println("[ERR][STORAGE][ADD_ACCOUNT] error in insert accounts query: " + err.Error()) } - return err + return id, err } func UpdateAccount(accountID string, account models.AccountUpdate) error { pgpool, pgctx := ReportInstance() var err error + // Set unit_groups array + unitGroupsStrs := make([]string, len(account.UnitGroups)) + for i, v := range account.UnitGroups { + unitGroupsStrs[i] = strconv.Itoa(v) + } + unitGroupsArray := "{" + strings.Join(unitGroupsStrs, ",") + "}" + _, err = pgpool.Exec(pgctx, + "UPDATE accounts set unit_groups = $1::int[], updated_at = NOW() WHERE id = $2", + unitGroupsArray, + accountID, + ) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update accounts query: " + err.Error()) + } if len(account.Password) > 0 { _, err = pgpool.Exec(pgctx, "UPDATE accounts set password = $1 WHERE id = $2", utils.HashPassword(account.Password), accountID, ) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update account password query: " + err.Error()) + return err + } } if len(account.DisplayName) > 0 { _, err = pgpool.Exec(pgctx, @@ -218,10 +242,12 @@ func UpdateAccount(accountID string, account models.AccountUpdate) error { account.DisplayName, accountID, ) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update account display name query: " + err.Error()) + return err + } } - if err != nil { - logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update accounts query: " + err.Error()) - } + return err } @@ -232,12 +258,13 @@ func IsAdmin(accountUsername string) (bool, string) { if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_PASSWORD] error in query execution:" + err.Error()) } + // Admin user has ID 1 return id == 1, string(rune(id)) } func GetAccounts() ([]models.Account, error) { pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, created FROM accounts") + rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, unit_groups, created_at, updated_at FROM accounts ORDER BY id ASC") if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query execution:" + err.Error()) } @@ -245,7 +272,7 @@ func GetAccounts() ([]models.Account, error) { var results []models.Account for rows.Next() { var accountRow models.Account - if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Created); err != nil { + if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.UnitGroups, &accountRow.Created, &accountRow.Updated); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query row extraction" + err.Error()) } accountRow.TwoFA = Is2FAEnabled(accountRow.Username) @@ -256,7 +283,7 @@ func GetAccounts() ([]models.Account, error) { func GetAccount(accountID string) ([]models.Account, error) { pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, created FROM accounts where id = $1", accountID) + rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, unit_groups, created_at, updated_at FROM accounts where id = $1", accountID) if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNT] error in query execution:" + err.Error()) } @@ -264,7 +291,7 @@ func GetAccount(accountID string) ([]models.Account, error) { var results []models.Account for rows.Next() { var accountRow models.Account - if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Created); err != nil { + if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.UnitGroups, &accountRow.Created, &accountRow.Updated); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNT] error in query row extraction" + err.Error()) } results = append(results, accountRow) @@ -298,6 +325,15 @@ func UpdatePassword(accountUsername string, newPassword string) error { utils.HashPassword(newPassword), accountUsername, ) + if err == nil { + // Update the updated_at timestamp + _, err = pgpool.Exec(pgctx, "UPDATE accounts SET updated_at = NOW() WHERE username = $1", accountUsername) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UPDATE_PASSWORD] error in updating updated_at timestamp: " + err.Error()) + } + } else { + logs.Logs.Println("[ERR][STORAGE][UPDATE_PASSWORD] error during update password: " + err.Error()) + } return err } @@ -528,3 +564,124 @@ func MigrateUnitInfoFromFileToPostgres() { } } } + +func UnitExists(uuid string) (bool, error) { + pgpool, pgctx := ReportInstance() + var exists bool + err := pgpool.QueryRow(pgctx, "SELECT EXISTS (SELECT 1 FROM units WHERE uuid = $1)", uuid).Scan(&exists) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UNIT_EXISTS] error in query execution:" + err.Error()) + return false, err + } + return exists, nil +} + +// AddUnitGroup adds a new unit group. Only admin can execute. +func AddUnitGroup(group models.UnitGroup) (int, error) { + pgpool, pgctx := ReportInstance() + var id int + unitArray := "{" + strings.Join(group.Units, ",") + "}" + err := pgpool.QueryRow(pgctx, + `INSERT INTO unit_groups (name, description, units, created_at, updated_at) VALUES ($1, $2, $3::uuid[], NOW(), NOW()) RETURNING id`, + group.Name, group.Description, unitArray).Scan(&id) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][ADD_UNIT_GROUP] error in insert unit_groups query: " + err.Error()) + } + return id, err +} + +func UpdateUnitGroup(groupId int, group models.UnitGroup) error { + pgpool, pgctx := ReportInstance() + unitArray := "{" + strings.Join(group.Units, ",") + "}" + res, err := pgpool.Exec(pgctx, + `UPDATE unit_groups SET name = $1, description = $2, units = $3::uuid[], updated_at = NOW() WHERE id = $4`, + group.Name, group.Description, unitArray, groupId) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][EDIT_UNIT_GROUP] error in update unit_groups query: " + err.Error()) + return err + } + if res.RowsAffected() == 0 { + logs.Logs.Println("[WARN][STORAGE][EDIT_UNIT_GROUP] no unit group updated with id " + strconv.Itoa(groupId)) + return fmt.Errorf("no unit group updated with id %d", groupId) + } + return nil +} + +func DeleteUnitGroup(groupID int) error { + pgpool, pgctx := ReportInstance() + res, err := pgpool.Exec(pgctx, `DELETE FROM unit_groups WHERE id = $1`, groupID) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][DELETE_UNIT_GROUP] error in delete unit_groups query: " + err.Error()) + return err + } + if res.RowsAffected() == 0 { + logs.Logs.Println("[WARN][STORAGE][DELETE_UNIT_GROUP] no unit group deleted with id " + strconv.Itoa(groupID)) + return fmt.Errorf("no unit group deleted with id %d", groupID) + } + return nil +} + +func ListUnitGroups() ([]models.UnitGroup, error) { + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, `SELECT id, name, description, units, created_at, updated_at FROM unit_groups`) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT_GROUPS] error in query execution:" + err.Error()) + return nil, err + } + defer rows.Close() + + var groups []models.UnitGroup + for rows.Next() { + var group models.UnitGroup + var unitsArray []string + if err := rows.Scan(&group.ID, &group.Name, &group.Description, &unitsArray, &group.CreatedAt, &group.UpdatedAt); err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT_GROUPS] error in query row extraction" + err.Error()) + continue + } + group.Units = unitsArray + groups = append(groups, group) + } + return groups, nil +} + +func GetUnitGroup(groupID int) (models.UnitGroup, error) { + pgpool, pgctx := ReportInstance() + row := pgpool.QueryRow(pgctx, `SELECT id, name, description, units, created_at, updated_at FROM unit_groups WHERE id = $1`, groupID) + + var group models.UnitGroup + var unitsArray []string + if err := row.Scan(&group.ID, &group.Name, &group.Description, &unitsArray, &group.CreatedAt, &group.UpdatedAt); err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT_GROUP] error in query row extraction" + err.Error()) + return group, err + } + group.Units = unitsArray + return group, nil +} + +func IsUnitGroupUsed(groupID int) (bool, error) { + pgpool, pgctx := ReportInstance() + var exists bool + query := ` + SELECT EXISTS ( + SELECT 1 FROM accounts + WHERE $1 = ANY(unit_groups) + ) + ` + err := pgpool.QueryRow(pgctx, query, groupID).Scan(&exists) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][IS_UNIT_GROUP_USED] error in query execution:" + err.Error()) + return false, err + } + return exists, nil +} + +func UnitGroupExists(groupID int) (bool, error) { + pgpool, pgctx := ReportInstance() + var exists bool + err := pgpool.QueryRow(pgctx, "SELECT EXISTS (SELECT 1 FROM unit_groups WHERE id = $1)", groupID).Scan(&exists) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UNIT_GROUP_EXISTS] error in query execution:" + err.Error()) + return false, err + } + return exists, nil +} From 9169ae816b37913cb9ea04bf1a7dfb6eca3608f1 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 26 Jun 2025 15:42:44 +0200 Subject: [PATCH 15/39] feat(proxy): add IP middleware Remove the IP middleware from the API server: let's Traefik handle the config This change is part of a refactor to improve security. --- README.md | 3 + api/README.md | 6 -- api/configuration/configuration.go | 42 -------------- api/main.go | 3 - api/main_test.go | 38 ------------- api/middleware/middleware.go | 36 ------------ proxy/entrypoint.sh | 88 +++++++++++++++++++++++++----- 7 files changed, 76 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index b565ea38..ec2a96ab 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ The following environment variables can be used to configure the containers: - `PROXY_PORT`: proxy listening port, default is `8080` - `PROXY_BIND_IP`: proxy binding IP, default is `0.0.0.0` - `REPORT_DB_URI`: Timescale DB URI, like `postgresql://user:password@host:port/dbname` +- `ALLOWED_IPS`: comma-separated list of allowed IPs, if empty, all IPs are allowed, default is empty +- `PUBLIC_ENDPOINTS`: comma-separated list of public endpoints, that can be accessed even if `ALLOWED_IPS` is set, default is empty + If ALLOWED_IPS is set, the public endpoints should allow registration and ingestions from units, a good value should be: `/api/ingest,/api/units/register` ## REST API diff --git a/api/README.md b/api/README.md index 3927c89d..0030c36f 100644 --- a/api/README.md +++ b/api/README.md @@ -65,12 +65,6 @@ CGO_ENABLED=0 go build - `ENCRYPTION_KEY`: key to encrypt/decrypt sensitive data, it must be 32 bytes long -- `TRUSTED_IPS`: list of trusted IPs/CIDRs to access the API - _default_: ``. - If the list is empty, all IPs are allowed. -- `TRUSTED_IP_EXCLUDE_PATHS`: list of paths to exclude from trusted IPs check - _default_: `` - This is useful to allow access to the API from the units without authentication. - Usually the only path that could be excluded is `/units/register`. - - `PLATFORM_INFO`: a JSON string with platform information, used to store the controller version and other information. It can be left empty. Example: `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "metrics_retention_days":30, "logs_retention_days":90}` diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 8f2bb636..47bf9a23 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -11,9 +11,7 @@ package configuration import ( "encoding/json" - "net" "os" - "slices" "strings" "github.com/NethServer/nethsecurity-controller/api/logs" @@ -77,9 +75,6 @@ type Configuration struct { EncryptionKey string `json:"encryption_key"` - TrustedIPs []net.IPNet `json:"trusted_ips"` - TrustedIPExcludePaths []string `json:"trusted_ip_exclude_paths"` - PlatformInfo models.PlatformInfo `json:"platform_info"` } @@ -320,43 +315,6 @@ func Init() { os.Exit(1) } - if os.Getenv("TRUSTED_IPS") != "" { - var cidrs []string - for _, entry := range strings.Split(os.Getenv("TRUSTED_IPS"), ",") { - entry = strings.TrimSpace(entry) - if entry != "" { - cidrs = append(cidrs, entry) - } - } - // Remove duplicates and sort - slices.Sort(cidrs) - cidrs = slices.Compact(cidrs) - for _, cidr := range cidrs { - if !strings.Contains(cidr, "/") { - cidr += "/32" - } - _, network, err := net.ParseCIDR(cidr) - if err == nil { - Config.TrustedIPs = append(Config.TrustedIPs, *network) - } - } - } else { - Config.TrustedIPs = []net.IPNet{} - } - - if os.Getenv("TRUSTED_IP_EXCLUDE_PATHS") != "" { - for _, entry := range strings.Split(os.Getenv("TRUSTED_IP_EXCLUDE_PATHS"), ",") { - entry = strings.TrimSpace(entry) - if entry != "" { - Config.TrustedIPExcludePaths = append(Config.TrustedIPExcludePaths, entry) - } - } - slices.Sort(Config.TrustedIPExcludePaths) - Config.TrustedIPExcludePaths = slices.Compact(Config.TrustedIPExcludePaths) - } else { - Config.TrustedIPExcludePaths = []string{} - } - if os.Getenv("PLATFORM_INFO") != "" { var platformInfo models.PlatformInfo err := json.Unmarshal([]byte(os.Getenv("PLATFORM_INFO")), &platformInfo) diff --git a/api/main.go b/api/main.go index b6bc623d..ba9e05d0 100644 --- a/api/main.go +++ b/api/main.go @@ -72,9 +72,6 @@ func setup() *gin.Engine { // init routers router := gin.Default() - // add trusted IP middleware globally - router.Use(middleware.TrustedIPMiddleware()) - // add default compression router.Use(gzip.Gzip(gzip.DefaultCompression)) diff --git a/api/main_test.go b/api/main_test.go index 31d9de53..124e4706 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -396,44 +396,6 @@ func TestMainEndpoints(t *testing.T) { }) } -// TestTrustedIPMiddleware tests the TrustedIPMiddleware for allowed and denied -func TestTrustedIPMiddleware(t *testing.T) { - os.Setenv("TRUSTED_IPS", "127.0.0.1,192.168.1.0/24") - os.Setenv("TRUSTED_IP_EXCLUDE_PATHS", "/units/register") - gin.SetMode(gin.TestMode) - restricted_router := setup() - - // Allowed: 127.0.0.1 - req := httptest.NewRequest("GET", "/health", nil) - req.RemoteAddr = "127.0.0.1:12345" - w := httptest.NewRecorder() - restricted_router.ServeHTTP(w, req) - if w.Code != 200 { - t.Errorf("expected 200 for allowed IP, got %d", w.Code) - } - - // Denied: 10.0.0.1 - req = httptest.NewRequest("GET", "/health", nil) - req.RemoteAddr = "10.0.0.1:12345" - w = httptest.NewRecorder() - restricted_router.ServeHTTP(w, req) - if w.Code != 403 { - t.Errorf("expected 403 for denied IP, got %d", w.Code) - } - - // Allowed: /units/register endpoint should be unrestricted - body := `{"unit_id": "11", "username": "aa", "unit_name": "bbb", "password": "ccc"}` - req = httptest.NewRequest("POST", "/units/register", bytes.NewBuffer([]byte(body))) - req.RemoteAddr = "10.0.0.1:12345" - req.Header.Set("Content-Type", "application/json") - req.Header.Set("RegistrationToken", "1234") - w = httptest.NewRecorder() - restricted_router.ServeHTTP(w, req) - if w.Code != 200 { - t.Errorf("expected 200 for /units/register endpoint, got %d", w.Code) - } -} - func addUnit(t *testing.T) string { // Generate a UUID v4 and convert it to string using the uuid package unitID := uuid.New().String() diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 9e82072a..595a46c0 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -13,7 +13,6 @@ import ( "bytes" "encoding/json" "io" - "net" "net/http" "os" "strings" @@ -343,38 +342,3 @@ func BasicUserAuth() gin.HandlerFunc { c.Next() } } - -// TrustedIPMiddleware restricts access to requests from trusted IPs/CIDRs defined in TRUSTED_IPS env var. -func TrustedIPMiddleware() gin.HandlerFunc { - if len(configuration.Config.TrustedIPs) == 0 { - // No restriction - return func(c *gin.Context) { - c.Next() - } - } - // Return the middleware handler - return func(c *gin.Context) { - // If path is inside TrustedIPExcludePaths, just skip the middleware - if len(configuration.Config.TrustedIPExcludePaths) > 0 { - for _, path := range configuration.Config.TrustedIPExcludePaths { - if strings.HasPrefix(c.Request.URL.Path, path) { - c.Next() - return - } - } - } - clientIP := c.ClientIP() - ip := net.ParseIP(clientIP) - if ip == nil { - c.AbortWithStatusJSON(403, gin.H{"error": "forbidden: invalid client IP"}) - return - } - for _, network := range configuration.Config.TrustedIPs { - if network.Contains(ip) { - c.Next() - return - } - } - c.AbortWithStatusJSON(403, gin.H{"error": "forbidden: IP not allowed"}) - } -} diff --git a/proxy/entrypoint.sh b/proxy/entrypoint.sh index 73394008..62cbf396 100755 --- a/proxy/entrypoint.sh +++ b/proxy/entrypoint.sh @@ -7,14 +7,73 @@ ip=${PROXY_BIND_IP:-0.0.0.0} ui_port=${UI_PORT:-3000} api_port=${API_PORT:-5000} -if [ ! -d $CONFIG_DIR ]; then - mkdir -p $CONFIG_DIR +# Optional environment variables for allowed IPs and public endpoints +# ALLOWED_IPS is a comma-separated list of IP/CDIRs that are allowed to access the proxy. Example: 1.2.3.0/24 +# PUBLIC_ENDPOINTS is a comma-separated list of public endpoints that should be accessible. Example: /api/units/ingest +allowed_ips=${ALLOWED_IPS:-""} +public_endpoints=${PUBLIC_ENDPOINTS:-""} + +output_public_routers () { + if [ -n "$public_endpoints" ]; then + OLD_IFS="$IFS" + IFS=, + set -- $public_endpoints + IFS="$OLD_IFS" + for endpoint; do + name=$(echo "$endpoint" | tr -cd '[:alnum:]') + printf " routerapi%s:\n" "$name" + printf " entryPoints:\n" + printf " - web\n" + printf " middlewares:\n" + printf " - stripprefix\n" + printf " service: service-api\n" + printf " rule: PathPrefix(\`%s\`)\n" "$endpoint" + done + fi +} + +output_middlewares_list () { + printf " - stripprefix\n" + if [ -n "$allowed_ips" ]; then + printf " - ipallowlist\n" + fi +} + +output_ui_middlewares_list () { + if [ -n "$allowed_ips" ]; then + printf " middlewares:\n" + printf " - ipallowlist\n" + fi +} + +output_whitelist_middleware () { + if [ -n "$allowed_ips" ]; then + printf " ipallowlist:\n" + printf " ipAllowList:\n" + printf " ipStrategy:\n" + printf " depth: 1\n" + printf " sourceRange:\n" + OLD_IFS="$IFS" + IFS=, + set -- $allowed_ips + IFS="$OLD_IFS" + for ip; do + printf " - \"%s\"\n" "$ip" + done + fi +} + +if [ ! -d "$CONFIG_DIR" ]; then + mkdir -p "$CONFIG_DIR" fi cat < /config.yaml entryPoints: web: address: "$ip:$port" + forwardedHeaders: + trustedIPs: + - "127.0.0.1/32" accessLog: {} @@ -28,19 +87,18 @@ serversTransport: EOF -cat << EOF > /etc/openvpn/proxy/api.yaml +cat << EOF > "${CONFIG_DIR}api.yaml" http: - # Add the router routers: +$(output_public_routers) routerapi: entryPoints: - web middlewares: - - mapi-stripprefix +$(output_middlewares_list) service: service-api rule: PathPrefix(\`/api\`) - # Add the service services: service-api: loadBalancer: @@ -48,33 +106,33 @@ http: - url: http://127.0.0.1:${api_port}/ passHostHeader: true - # Add middleware middlewares: - mapi-stripprefix: + stripprefix: stripPrefix: prefixes: - "/api" +$(output_whitelist_middleware) EOF -cat << EOF > /etc/openvpn/proxy/ui.yaml +cat << EOF > "${CONFIG_DIR}ui.yaml" http: - # Add the router routers: +$(output_public_routers) + routerui: entryPoints: - web middlewares: - - mui-stripprefix +$(output_middlewares_list) service: service-ui rule: PathPrefix(\`/ui\`) - routerui-root: entryPoints: - web +$(output_ui_middlewares_list) service: service-ui rule: PathPrefix(\`/\`) - # Add the service services: service-ui: loadBalancer: @@ -82,12 +140,12 @@ http: - url: http://127.0.0.1:${ui_port}/ passHostHeader: true - # Add middleware middlewares: - mui-stripprefix: + stripprefix: stripPrefix: prefixes: - "/ui" +$(output_whitelist_middleware) EOF exec "$@" From 99eb6bf883dcab4e081c7fd08a45b0e51cdbf0a6 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 26 Jun 2025 14:21:43 +0200 Subject: [PATCH 16/39] feat(api)!: implement unit authorizations --- api/README.md | 18 ++- api/main.go | 5 +- api/main_test.go | 126 ++++++++++++++- api/methods/account.go | 10 +- api/methods/auth.go | 17 ++ api/methods/unit.go | 46 +++++- api/middleware/middleware.go | 23 ++- api/models/account.go | 10 +- api/storage/report_schema.sql.tmpl | 1 + api/storage/storage.go | 239 +++++++++++++++++++++++------ api/utils/utils.go | 2 - 11 files changed, 418 insertions(+), 79 deletions(-) diff --git a/api/README.md b/api/README.md index 0030c36f..df7e8c58 100644 --- a/api/README.md +++ b/api/README.md @@ -78,17 +78,19 @@ Units can be added to a group via the API. The group is identified by a unique I A user is an account that can access the API and the UI. User accounts are stored in the database and can be managed via the API. -By default, a user can access all units but this can be restricted by assigning the user to a group of units. +By default, a user can't access any unit. +A user can be promoted to an admin user, which allows the user to manage other users and units. -There is also a special `admin` user with id `1` that can: +Admin users have the `admin` flag set to `true` and can: - create, modify and delete user accounts - create, modify and delete units - create, modify and delete units groups - assign a user to one or more groups of units +- see all units, despite the groups assigned to the user The following rules apply: -- a user can be assigned to one or more groups of units, if the user is not assigned to any group, the user can access all units +- a user can be assigned to one or more groups of units, if the user is not assigned to any group, the user can't see any unit - a non existing-unit cannot be added to a group - a unit group that is associated to a user account can't be deleted - a non-existing unit group cannot be assigned to a user account @@ -629,6 +631,7 @@ The following rules apply: "id": 2, "username": "test1", "password": "", + "admin": true, "display_name": "Test 1", "created": "2024-03-14T09:37:28+01:00" }, @@ -637,6 +640,7 @@ The following rules apply: "id": 6, "username": "test2", "password": "", + "admin": false, "display_name": "Test 2", "created": "2024-03-14T11:43:33+01:00" } @@ -670,6 +674,7 @@ The following rules apply: "id": 2, "username": "test3", "password": "", + "admin": false, "display_name": "Test 3", "created": "2024-03-14T09:37:28+01:00" } @@ -689,7 +694,9 @@ The following rules apply: { "username": "test1", "password": "Nethesis,1234", - "display_name": "Test 1" + "display_name": "Test 1", + "unit_groups": [1, 2], + "admin": false } ``` @@ -717,7 +724,8 @@ The following rules apply: { "password": "Nethesis,4321", "display_name": "Test 5", - "unit_groups": [1, 2] + "unit_groups": [1, 2], + "admin": false } ``` diff --git a/api/main.go b/api/main.go index ba9e05d0..7e45f273 100644 --- a/api/main.go +++ b/api/main.go @@ -171,7 +171,10 @@ func setup() *gin.Engine { // Forwarded authentication middleware forwarded := router.Group("/auth", middleware.BasicUserAuth()) forwarded.GET("", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"auth": "ok"}) + c.Status(http.StatusOK) + }) + forwarded.GET("/:unit_id", func(c *gin.Context) { + c.Status(http.StatusOK) }) // handle missing endpoint diff --git a/api/main_test.go b/api/main_test.go index 124e4706..e54b5f8b 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -208,7 +208,7 @@ func TestMainEndpoints(t *testing.T) { t.Run("TestAddUpdateDeleteAccount", func(t *testing.T) { w := httptest.NewRecorder() // Add account - addBody := `{"username": "testuser", "password": "testpass", "display_name": "Test User"}` + addBody := `{"username": "testuser", "password": "testpass", "admin": false, "display_name": "Test User"}` addReq, _ := http.NewRequest("POST", "/accounts", bytes.NewBuffer([]byte(addBody))) addReq.Header.Set("Content-Type", "application/json") addReq.Header.Set("Authorization", "Bearer "+token) @@ -235,7 +235,7 @@ func TestMainEndpoints(t *testing.T) { } assert.NotEmpty(t, testAccountID) // Update account display name - updateBody := `{"display_name": "Updated User", "unit_groups": []}` + updateBody := `{"display_name": "Updated User", "unit_groups": [], "admin": false}` updateReq, _ := http.NewRequest("PUT", "/accounts/"+testAccountID, bytes.NewBuffer([]byte(updateBody))) updateReq.Header.Set("Content-Type", "application/json") updateReq.Header.Set("Authorization", "Bearer "+token) @@ -745,6 +745,128 @@ func TestUnitGroupsAPI(t *testing.T) { // DELETE "/units/"+unitId_1 can't be tested because it requires easy-rsa binary and configuration file } +func TestUnitAuthorization(t *testing.T) { + router = setupRouter() + unitId_1 := addUnit(t) + unitId_2 := addUnit(t) + + randCounter := fmt.Sprintf("%d", mathrand.New(mathrand.NewSource(time.Now().UnixNano())).Intn(10000)) + + // Login to get token + w := httptest.NewRecorder() + loginBody := []byte(`{"username":"admin","password":"admin"}`) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(loginBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var loginResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&loginResp) + token, ok := loginResp["token"].(string) + assert.True(t, ok) + assert.NotEmpty(t, token) + + // Create unit group with unitId_1 + w = httptest.NewRecorder() + groupName := fmt.Sprintf("authgroup%s", randCounter) + groupBody := []byte(`{"name":"` + groupName + `","description":"auth test group", "units":["` + unitId_1 + `"]}`) + req, _ = http.NewRequest("POST", "/unit_groups", bytes.NewBuffer(groupBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var groupResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&groupResp) + groupData := groupResp["data"].(map[string]interface{}) + groupID := fmt.Sprintf("%v", groupData["id"]) + assert.NotEmpty(t, groupID) + + // Create limited account associated to the unit group + w = httptest.NewRecorder() + limitedUserName := fmt.Sprintf("limited%s", randCounter) + addBody := `{"username": "` + limitedUserName + `", "password": "limited", "display_name": "Limited user", "unit_groups": [` + groupID + `]}` + addReq, _ := http.NewRequest("POST", "/accounts", bytes.NewBuffer([]byte(addBody))) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, addReq) + assert.Equal(t, http.StatusCreated, w.Code) + var addUserResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&addUserResp) + limitedAccountID := fmt.Sprintf("%v", addUserResp["data"].(map[string]interface{})["id"]) + assert.NotEmpty(t, limitedAccountID) + + // Test /auth/ with limited user - should return 200 OK + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/auth/"+unitId_1, nil) + req.SetBasicAuth(limitedUserName, "limited") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "limited user should have access to unitId_1") + authUser := w.Header().Get("X-Auth-User") + assert.Equal(t, limitedUserName, authUser, "X-Auth-User header should be set to limited user") + + // Test /auth/ with limited user - should return 403 Forbidden + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/auth/"+unitId_2, nil) + req.SetBasicAuth(limitedUserName, "limited") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "limited user should not have access to unitId_2") + + // Login with limited user to get their token + w = httptest.NewRecorder() + limitedLoginBody := []byte(`{"username":"` + limitedUserName + `","password":"limited"}`) + req, _ = http.NewRequest("POST", "/login", bytes.NewBuffer(limitedLoginBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var limitedLoginResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&limitedLoginResp) + limitedToken, ok := limitedLoginResp["token"].(string) + assert.True(t, ok) + assert.NotEmpty(t, limitedToken) + + // // Test GET /units/ with limited user - should return 200 OK + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/units/"+unitId_1, nil) + req.Header.Set("Authorization", "Bearer "+limitedToken) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "limited user should be able to get unitId_1") + + // Test GET /units/ with limited user - should return 403 Forbidden + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/units/"+unitId_2, nil) + req.Header.Set("Authorization", "Bearer "+limitedToken) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "limited user should not be able to get unitId_2") + + // Test GET /units with limited user - should only return unitId_1 + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/units", nil) + req.Header.Set("Authorization", "Bearer "+limitedToken) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var unitsResp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&unitsResp) + unitsData := unitsResp["data"].([]interface{}) + assert.Len(t, unitsData, 1, "limited user should only see one unit") + unit := unitsData[0].(map[string]interface{}) + assert.Equal(t, unitId_1, unit["id"], "limited user should only see unitId_1") + + // Limited user tries to delete unitId_1 (should fail with 403) + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", "/units/"+unitId_1, nil) + req.Header.Set("Authorization", "Bearer "+limitedToken) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "limited user should not be able to delete unitId_1") + + // Limited user tries to add a new unit (should fail with 403) + w = httptest.NewRecorder() + addUnitBody := []byte(`{"unit_id":"shouldfail","username":"failuser","unit_name":"Should Fail Unit","password":"failpass"}`) + req, _ = http.NewRequest("POST", "/units", bytes.NewBuffer(addUnitBody)) + req.Header.Set("Authorization", "Bearer "+limitedToken) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "limited user should not be able to add a unit") +} + func setupRouter() *gin.Engine { // Singleton if router != nil { diff --git a/api/methods/account.go b/api/methods/account.go index 31f0f40d..f6a9f544 100644 --- a/api/methods/account.go +++ b/api/methods/account.go @@ -29,7 +29,7 @@ import ( func GetAccounts(c *gin.Context) { // check auth for not admin users - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -71,7 +71,7 @@ func GetAccounts(c *gin.Context) { func GetAccount(c *gin.Context) { // check auth for not admin users - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -116,7 +116,7 @@ func GetAccount(c *gin.Context) { func AddAccount(c *gin.Context) { // check auth for not admin users - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -160,7 +160,7 @@ func UpdateAccount(c *gin.Context) { accountID := c.Param("account_id") // check auth for not admin users - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -221,7 +221,7 @@ func UpdateAccount(c *gin.Context) { func DeleteAccount(c *gin.Context) { // check auth - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, diff --git a/api/methods/auth.go b/api/methods/auth.go index d967a730..d08fdfdb 100644 --- a/api/methods/auth.go +++ b/api/methods/auth.go @@ -373,3 +373,20 @@ func generateRecoveryCodes() []string { } return recoveryCodes } + +func UserCanAccessUnit(user string, unitID string) bool { + if storage.IsAdmin(user) { + return true + } + userUnits := storage.GetUserUnits() + units, ok := userUnits[user] + if !ok { + return false + } + for _, u := range units { + if u == unitID { + return true + } + } + return false +} diff --git a/api/methods/unit.go b/api/methods/unit.go index c6f68ab5..15551161 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -24,6 +24,7 @@ import ( "github.com/NethServer/nethsecurity-api/response" "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/NethServer/nethsecurity-controller/api/models" + "github.com/NethServer/nethsecurity-controller/api/socket" "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" @@ -34,6 +35,9 @@ import ( ) func GetUnits(c *gin.Context) { + // extract user from JWT claims + user := jwt.ExtractClaims(c)["id"].(string) + // list file in OpenVPNCCDDir units, err := ListUnits() if err != nil { @@ -48,6 +52,9 @@ func GetUnits(c *gin.Context) { // loop through units var results []gin.H for _, unit := range units { + if !UserCanAccessUnit(user, unit) { + continue + } // read unit file result, err := getUnitInfo(unit) if err != nil { @@ -73,6 +80,15 @@ func GetUnits(c *gin.Context) { func GetUnit(c *gin.Context) { // get unit id unitId := c.Param("unit_id") + user := jwt.ExtractClaims(c)["id"].(string) + if !UserCanAccessUnit(user, unitId) { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "user does not have access to this unit", + Data: nil, + })) + return + } // parse unit file result, err := getUnitInfo(unitId) @@ -176,6 +192,16 @@ func AddInfo(c *gin.Context) { } func AddUnit(c *gin.Context) { + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "can't access this resource", + Data: nil, + })) + return + } + // parse request fields var jsonRequest models.AddRequest if err := c.ShouldBindJSON(&jsonRequest); err != nil { @@ -496,6 +522,16 @@ func RegisterUnit(c *gin.Context) { } func DeleteUnit(c *gin.Context) { + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + if !isAdmin { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "can't access this resource", + Data: nil, + })) + return + } + // get unit id unitId := c.Param("unit_id") @@ -814,7 +850,7 @@ func parseUnitFile(unitId string, unitFile []byte) gin.H { } func AddUnitGroup(c *gin.Context) { - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -851,7 +887,7 @@ func AddUnitGroup(c *gin.Context) { } func UpdateUnitGroup(c *gin.Context) { - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -924,7 +960,7 @@ func UpdateUnitGroup(c *gin.Context) { } func DeleteUnitGroup(c *gin.Context) { - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -985,7 +1021,7 @@ func DeleteUnitGroup(c *gin.Context) { } func ListUnitGroups(c *gin.Context) { - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, @@ -1012,7 +1048,7 @@ func ListUnitGroups(c *gin.Context) { } func GetUnitGroup(c *gin.Context) { - isAdmin, _ := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) + isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ Code: 403, diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 595a46c0..a4599750 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -98,7 +98,7 @@ func InitJWT() *jwt.GinJWTMiddleware { role := "user" // check if username is admin - isAdmin, _ := storage.IsAdmin(user.Username) + isAdmin := storage.IsAdmin(user.Username) if isAdmin { role = "admin" } @@ -323,9 +323,8 @@ func BasicUserAuth() gin.HandlerFunc { // check password and username valid := utils.CheckPasswordHash(password, passwordHash) - isAdmin, _ := storage.IsAdmin(username) - if !valid || !isAdmin { + if !valid { c.JSON(http.StatusUnauthorized, structs.Map(response.StatusUnauthorized{ Code: 401, Message: "invalid username or password", @@ -336,8 +335,24 @@ func BasicUserAuth() gin.HandlerFunc { return } + // Optionally load unit_id from query or header + unitID := c.Param("unit_id") + extra_log := "" + if unitID != "" { + if !methods.UserCanAccessUnit(username, unitID) { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "user does not have access to this unit", + Data: nil, + })) + logs.Logs.Println("[INFO][AUTH] user " + username + " does not have access to unit " + unitID) + c.Abort() + return + } + extra_log = " to unit " + unitID + } // Just return success - logs.Logs.Println("[INFO][AUTH] user " + username + " authenticated successfully") + logs.Logs.Println("[INFO][AUTH] user "+username+" authenticated successfully", extra_log) c.Header("X-Auth-User", username) c.Next() } diff --git a/api/models/account.go b/api/models/account.go index c0746156..ce35092f 100644 --- a/api/models/account.go +++ b/api/models/account.go @@ -14,9 +14,11 @@ import ( ) type Account struct { - ID int `json:"id" structs:"id"` - Username string `json:"username" structs:"username" binding:"required,excludesall= "` - Password string `json:"password" structs:"password" db:"-" binding:"required"` + ID int `json:"id" structs:"id"` + Username string `json:"username" structs:"username" binding:"required,excludesall= "` + Password string `json:"password" structs:"password" db:"-" binding:"required"` + // Watch out: un/marshalling booleans is a pain, see https://github.com/gin-gonic/gin/issues/814 + Admin bool `json:"admin" structs:"admin"` DisplayName string `json:"display_name" structs:"display_name"` UnitGroups []int `json:"unit_groups" structs:"unit_groups"` Created time.Time `json:"created" structs:"created_at"` @@ -25,8 +27,8 @@ type Account struct { } type AccountUpdate struct { - Password string `json:"password" structs:"password"` DisplayName string `json:"display_name" structs:"display_name"` + Admin bool `json:"admin" structs:"admin"` UnitGroups []int `json:"unit_groups" structs:"unit_groups" binding:"required"` } diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index 76d45da8..1c9fb295 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS accounts ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, + admin BOOLEAN NOT NULL DEFAULT FALSE, display_name TEXT, otp_secret TEXT, otp_recovery_codes TEXT, diff --git a/api/storage/storage.go b/api/storage/storage.go index 2c701b6d..5d698eae 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -46,15 +46,23 @@ var grafanaUserSQL string var reportDbIsInitialized = false +// userUnits is a map that holds the units for each user. +var userUnits = make(map[string][]string) + +// adminUsers is a list of user names that has the admin flag +var adminUsers = make([]string, 0) + func Init() *pgxpool.Pool { // Initialize PostgreSQL connection and schema dbpool, dbctx = InitReportDb() + // Migrate unit info from file to Postgres if needed + migrated_units := MigrateUnitInfoFromFileToPostgres() + // Migrate users from SQLite to Postgres if needed - MigrateUsersFromSqliteToPostgres() + MigrateUsersFromSqliteToPostgres(migrated_units) - // Migrate unit info from file to Postgres if needed - MigrateUnitInfoFromFileToPostgres() + ReloadACLs() // Initialize PostgreSQL connection dbctx = context.Background() @@ -81,6 +89,7 @@ func Init() *pgxpool.Pool { admin := models.Account{ Username: configuration.Config.AdminUsername, Password: configuration.Config.AdminPassword, + Admin: true, DisplayName: "Administrator", Created: time.Now(), } @@ -91,7 +100,7 @@ func Init() *pgxpool.Pool { } // MigrateUsersFromSqliteToPostgres migrates users from SQLite to PostgreSQL if needed -func MigrateUsersFromSqliteToPostgres() { +func MigrateUsersFromSqliteToPostgres(units []string) { // 1. Check if SQLite DB exists sqlitePath := configuration.Config.DataDir + "/db.sqlite" if _, err := os.Stat(sqlitePath); os.IsNotExist(err) { @@ -122,6 +131,7 @@ func MigrateUsersFromSqliteToPostgres() { continue } acc.Created, _ = time.Parse(time.RFC3339, createdStr) + acc.Admin = true users = append(users, acc) } if len(users) == 0 { @@ -131,7 +141,7 @@ func MigrateUsersFromSqliteToPostgres() { // 4. Check if admin user exists in Postgres pgpool, pgctx := ReportInstance() var adminExists bool - err = pgpool.QueryRow(pgctx, `SELECT EXISTS (SELECT 1 FROM accounts WHERE username = $1)`, configuration.Config.AdminUsername).Scan(&adminExists) + err = pgpool.QueryRow(pgctx, `SELECT EXISTS (SELECT 1 FROM accounts WHERE admin = true)`).Scan(&adminExists) if err != nil { logs.Logs.Println("[ERR][MIGRATION] error checking admin user in Postgres: " + err.Error()) return @@ -140,11 +150,27 @@ func MigrateUsersFromSqliteToPostgres() { return // Admin user exists, nothing to do } - // 5. Create accounts table in Postgres + // 5. Create a unit_group with all units + groupID := -1 + var groupErr error + if len(units) > 0 { + group := models.UnitGroup{ + Name: "Migrated", + Units: units, + } + groupID, groupErr = AddUnitGroup(group) + // Create a unit group for the migrated units + if groupErr != nil { + logs.Logs.Println("[ERR][MIGRATION] error creating unit group in Postgres: " + err.Error()) + } + } + + // 6. Create accounts table in Postgres _, err = pgpool.Exec(pgctx, `CREATE TABLE IF NOT EXISTS accounts ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, + admin BOOLEAN NOT NULL DEFAULT FALSE, display_name TEXT, otp_secret TEXT, otp_recovery_codes TEXT, @@ -174,11 +200,18 @@ func MigrateUsersFromSqliteToPostgres() { } // remove acc.Username directory os.RemoveAll(configuration.Config.SecretsDir + "/" + acc.Username) - - _, err := pgpool.Exec(pgctx, `INSERT INTO accounts (username, password, display_name, otp_secret, otp_recovery_codes, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (username) DO NOTHING`, - acc.Username, acc.Password, acc.DisplayName, otp_secret, recoveryCodes, acc.Created) - if err != nil { - logs.Logs.Println("[ERR][MIGRATION] error inserting user into Postgres: " + err.Error()) + acc.UnitGroups = []int{groupID} // Reset unit groups for migration + // Insert user into Postgres + _, accountError := AddAccount(acc) // Use AddAccount to handle password hashing and other logic + if accountError != nil { + logs.Logs.Println("[ERR][MIGRATION] error migrating user to Postgres: " + accountError.Error()) + } + // Set OTP secret + if err := SetUserOtpSecret(acc.Username, otp_secret); err != nil { + logs.Logs.Println("[ERR][MIGRATION] error mirating OTP secret for user", acc.Username, ":", err.Error()) + } + if err := SetUserRecoveryCodes(acc.Username, strings.Split(recoveryCodes, "|")); err != nil { + logs.Logs.Println("[ERR][MIGRATION] error mirating recovery codes for user", acc.Username, ":", err.Error()) } } logs.Logs.Println("[INFO][MIGRATION] migrated", len(users), "users from SQLite to Postgres") @@ -195,9 +228,10 @@ func AddAccount(account models.Account) (int, error) { pgpool, pgctx := ReportInstance() var id int err := pgpool.QueryRow(pgctx, - "INSERT INTO accounts (username, password, display_name, unit_groups, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id", + "INSERT INTO accounts (username, password, admin, display_name, unit_groups, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", account.Username, utils.HashPassword(account.Password), + account.Admin, account.DisplayName, account.UnitGroups, account.Created, @@ -205,6 +239,9 @@ func AddAccount(account models.Account) (int, error) { if err != nil { logs.Logs.Println("[ERR][STORAGE][ADD_ACCOUNT] error in insert accounts query: " + err.Error()) } + + ReloadACLs() + return id, err } @@ -218,53 +255,39 @@ func UpdateAccount(accountID string, account models.AccountUpdate) error { } unitGroupsArray := "{" + strings.Join(unitGroupsStrs, ",") + "}" _, err = pgpool.Exec(pgctx, - "UPDATE accounts set unit_groups = $1::int[], updated_at = NOW() WHERE id = $2", + `UPDATE accounts + SET unit_groups = $1::int[], + display_name = $2, + admin = $3, + updated_at = NOW() + WHERE id = $4`, unitGroupsArray, + account.DisplayName, + account.Admin, accountID, ) if err != nil { logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update accounts query: " + err.Error()) + return err } - if len(account.Password) > 0 { - _, err = pgpool.Exec(pgctx, - "UPDATE accounts set password = $1 WHERE id = $2", - utils.HashPassword(account.Password), - accountID, - ) - if err != nil { - logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update account password query: " + err.Error()) - return err - } - } - if len(account.DisplayName) > 0 { - _, err = pgpool.Exec(pgctx, - "UPDATE accounts set display_name = $1 WHERE id = $2", - account.DisplayName, - accountID, - ) - if err != nil { - logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update account display name query: " + err.Error()) - return err - } - } + + ReloadACLs() return err } -func IsAdmin(accountUsername string) (bool, string) { - pgpool, pgctx := ReportInstance() - var id int - err := pgpool.QueryRow(pgctx, "SELECT id FROM accounts where username = $1 LIMIT 1", accountUsername).Scan(&id) - if err != nil { - logs.Logs.Println("[ERR][STORAGE][GET_PASSWORD] error in query execution:" + err.Error()) +func IsAdmin(accountUsername string) bool { + for _, admin := range adminUsers { + if admin == accountUsername { + return true + } } - // Admin user has ID 1 - return id == 1, string(rune(id)) + return false } func GetAccounts() ([]models.Account, error) { pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, unit_groups, created_at, updated_at FROM accounts ORDER BY id ASC") + rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, admin, unit_groups, created_at, updated_at FROM accounts ORDER BY id ASC") if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query execution:" + err.Error()) } @@ -272,7 +295,7 @@ func GetAccounts() ([]models.Account, error) { var results []models.Account for rows.Next() { var accountRow models.Account - if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.UnitGroups, &accountRow.Created, &accountRow.Updated); err != nil { + if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Admin, &accountRow.UnitGroups, &accountRow.Created, &accountRow.Updated); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNTS] error in query row extraction" + err.Error()) } accountRow.TwoFA = Is2FAEnabled(accountRow.Username) @@ -283,7 +306,7 @@ func GetAccounts() ([]models.Account, error) { func GetAccount(accountID string) ([]models.Account, error) { pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, unit_groups, created_at, updated_at FROM accounts where id = $1", accountID) + rows, err := pgpool.Query(pgctx, "SELECT id, username, display_name, admin, unit_groups, created_at, updated_at FROM accounts where id = $1", accountID) if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNT] error in query execution:" + err.Error()) } @@ -291,7 +314,7 @@ func GetAccount(accountID string) ([]models.Account, error) { var results []models.Account for rows.Next() { var accountRow models.Account - if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.UnitGroups, &accountRow.Created, &accountRow.Updated); err != nil { + if err := rows.Scan(&accountRow.ID, &accountRow.Username, &accountRow.DisplayName, &accountRow.Admin, &accountRow.UnitGroups, &accountRow.Created, &accountRow.Updated); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_ACCOUNT] error in query row extraction" + err.Error()) } results = append(results, accountRow) @@ -315,6 +338,9 @@ func DeleteAccount(accountID string) error { if err != nil { logs.Logs.Println("[ERR][STORAGE][DELETE_ACCOUNT] error in query execution:" + err.Error()) } + + ReloadACLs() + return err } @@ -513,7 +539,7 @@ func GetUnitInfo(uuid string) map[string]interface{} { SELECT info::text FROM units WHERE uuid = $1 `, uuid).Scan(&infoStr) if err != nil { - logs.Logs.Println("[ERR][STORAGE][GET_UNIT_INFO] error in query execution:" + err.Error()) + // No info found for this unit, return nil return nil } var info map[string]interface{} @@ -524,16 +550,17 @@ func GetUnitInfo(uuid string) map[string]interface{} { return info } -func MigrateUnitInfoFromFileToPostgres() { +func MigrateUnitInfoFromFileToPostgres() []string { + ret := make([]string, 0) // Search for all *.info file inside Config.OpenVPNStatusDir // If the dir does not exists, just return if _, err := os.Stat(configuration.Config.OpenVPNStatusDir); os.IsNotExist(err) { - return + return ret } files, err := os.ReadDir(configuration.Config.OpenVPNStatusDir) if err != nil { logs.Logs.Println("[WARNING][MIGRATION] error reading OpenVPN status directory: " + err.Error()) - return + return ret } for _, file := range files { if !file.IsDir() && strings.HasSuffix(file.Name(), ".info") { @@ -561,8 +588,10 @@ func MigrateUnitInfoFromFileToPostgres() { } else { logs.Logs.Println("[INFO][MIGRATION] removed file:", filePath) } + ret = append(ret, uuid) } } + return ret } func UnitExists(uuid string) (bool, error) { @@ -587,6 +616,9 @@ func AddUnitGroup(group models.UnitGroup) (int, error) { if err != nil { logs.Logs.Println("[ERR][STORAGE][ADD_UNIT_GROUP] error in insert unit_groups query: " + err.Error()) } + + ReloadACLs() + return id, err } @@ -604,6 +636,9 @@ func UpdateUnitGroup(groupId int, group models.UnitGroup) error { logs.Logs.Println("[WARN][STORAGE][EDIT_UNIT_GROUP] no unit group updated with id " + strconv.Itoa(groupId)) return fmt.Errorf("no unit group updated with id %d", groupId) } + + ReloadACLs() + return nil } @@ -618,6 +653,9 @@ func DeleteUnitGroup(groupID int) error { logs.Logs.Println("[WARN][STORAGE][DELETE_UNIT_GROUP] no unit group deleted with id " + strconv.Itoa(groupID)) return fmt.Errorf("no unit group deleted with id %d", groupID) } + + ReloadACLs() + return nil } @@ -685,3 +723,102 @@ func UnitGroupExists(groupID int) (bool, error) { } return exists, nil } + +func GetUserUnits() map[string][]string { + // If userUnits is already loaded, return it + if len(userUnits) > 0 { + return userUnits + } + + // Load user units from the database + userUnitsMap, err := LoadUserUnitsMap() + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_USER_UNITS] error loading user units: " + err.Error()) + return nil + } + + userUnits = userUnitsMap + return userUnits +} + +func LoadUserUnitsMap() (map[string][]string, error) { + pgpool, pgctx := ReportInstance() + UserUnits := make(map[string][]string) + + // Use a join to get username and units in a single query + rows, err := pgpool.Query(pgctx, ` + SELECT a.username, COALESCE(u.units, '{}') AS units + FROM accounts a + LEFT JOIN LATERAL ( + SELECT array_agg(DISTINCT group_id) AS group_ids + FROM accounts acc, unnest(acc.unit_groups) AS group_id + WHERE acc.username = a.username AND acc.unit_groups IS NOT NULL AND array_length(acc.unit_groups, 1) > 0 + ) ag ON true + LEFT JOIN LATERAL ( + SELECT array_agg(DISTINCT unit_id) AS units + FROM unit_groups ug, unnest(ug.units) AS unit_id + WHERE ag.group_ids IS NOT NULL AND ug.id = ANY(ag.group_ids) + ) u ON true + `) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_USER_UNITS_MAP] error in query execution:" + err.Error()) + return nil, err + } + defer rows.Close() + + for rows.Next() { + var username string + var unitsArr []string + if err := rows.Scan(&username, &unitsArr); err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_USER_UNITS_MAP] error in row scan: " + err.Error()) + continue + } + // If unitsArr is nil, assign empty slice + if unitsArr == nil { + unitsArr = []string{} + } + UserUnits[username] = unitsArr + } + return UserUnits, nil +} + +func LoadAdminUsersList() ([]string, error) { + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, "SELECT username FROM accounts WHERE admin = true") + if err != nil { + logs.Logs.Println("[ERR][STORAGE][LOAD_ADMIN_USERS] error in query execution:" + err.Error()) + return nil, err + } + defer rows.Close() + + admins := make([]string, 0) + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + logs.Logs.Println("[ERR][STORAGE][LOAD_ADMIN_USERS] error in row scan: " + err.Error()) + continue + } + admins = append(admins, username) + } + return admins, nil +} + +func ReloadACLs() { + // Reload user units from the database + userUnitsMap, err := LoadUserUnitsMap() + if err != nil { + logs.Logs.Println("[ERR][STORAGE][RELOAD_USER_UNITS] error loading user units: " + err.Error()) + return + } + userUnits = userUnitsMap + logs.Logs.Println("[INFO][STORAGE][RELOAD_USER_UNITS] user units reloaded successfully") + + // Reload admin users + admins, err := LoadAdminUsersList() + if err != nil { + logs.Logs.Println("[ERR][STORAGE][RELOAD_ADMIN_USERS] error loading admin users: " + err.Error()) + return + } + adminUsers = admins + logs.Logs.Println("[INFO][STORAGE][RELOAD_ADMIN_USERS] admin users reloaded successfully") +} diff --git a/api/utils/utils.go b/api/utils/utils.go index 0e441891..e09f3f55 100644 --- a/api/utils/utils.go +++ b/api/utils/utils.go @@ -12,7 +12,6 @@ package utils import ( "encoding/base64" "encoding/json" - "fmt" "net" "strconv" @@ -147,7 +146,6 @@ func EncryptAESGCM(plaintext, key []byte) ([]byte, error) { func EncryptAESGCMToString(plaintext, key []byte) (string, error) { ciphertext, err := EncryptAESGCM(plaintext, key) if err != nil { - fmt.Println("error", err) return "", err } return base64.StdEncoding.EncodeToString(ciphertext), nil From a4e4964d34ec04243b17b29925759f73fdc43c25 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 27 Jun 2025 14:14:39 +0200 Subject: [PATCH 17/39] fix(api): correctly migrate users from sqlite Also simplify database migration --- api/storage/storage.go | 103 ++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 58 deletions(-) diff --git a/api/storage/storage.go b/api/storage/storage.go index 5d698eae..11bcae71 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -44,8 +44,6 @@ var upgradeSchemaSQL string //go:embed grafana_user.sql.tmpl var grafanaUserSQL string -var reportDbIsInitialized = false - // userUnits is a map that holds the units for each user. var userUnits = make(map[string][]string) @@ -116,7 +114,7 @@ func MigrateUsersFromSqliteToPostgres(units []string) { defer sqliteDB.Close() // 3. Check if SQLite has users - rows, err := sqliteDB.Query("SELECT id, username, password, display_name, created_at FROM accounts") + rows, err := sqliteDB.Query("SELECT id, username, password, display_name, created FROM accounts") if err != nil { logs.Logs.Println("[ERR][MIGRATION] cannot query SQLite accounts: " + err.Error()) return @@ -155,8 +153,9 @@ func MigrateUsersFromSqliteToPostgres(units []string) { var groupErr error if len(units) > 0 { group := models.UnitGroup{ - Name: "Migrated", - Units: units, + Name: "Migrated", + Description: "All units migrated from old release", + Units: units, } groupID, groupErr = AddUnitGroup(group) // Create a unit group for the migrated units @@ -165,24 +164,6 @@ func MigrateUsersFromSqliteToPostgres(units []string) { } } - // 6. Create accounts table in Postgres - _, err = pgpool.Exec(pgctx, `CREATE TABLE IF NOT EXISTS accounts ( - id SERIAL PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - admin BOOLEAN NOT NULL DEFAULT FALSE, - display_name TEXT, - otp_secret TEXT, - otp_recovery_codes TEXT, - unit_groups []int, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP), - )`) - if err != nil { - logs.Logs.Println("[ERR][MIGRATION] error creating accounts table in Postgres: " + err.Error()) - return - } - // 6. Insert users into Postgres for _, acc := range users { // Read OTP secret @@ -200,12 +181,20 @@ func MigrateUsersFromSqliteToPostgres(units []string) { } // remove acc.Username directory os.RemoveAll(configuration.Config.SecretsDir + "/" + acc.Username) - acc.UnitGroups = []int{groupID} // Reset unit groups for migration + if groupID > 0 { + acc.UnitGroups = []int{groupID} // Set unit group for the user + } // Insert user into Postgres _, accountError := AddAccount(acc) // Use AddAccount to handle password hashing and other logic if accountError != nil { logs.Logs.Println("[ERR][MIGRATION] error migrating user to Postgres: " + accountError.Error()) } + // Set the password directly using a raw query + _, rawPasswordError := pgpool.Exec(pgctx, "UPDATE accounts SET password = $1 WHERE username = $2", acc.Password, acc.Username) + if rawPasswordError != nil { + logs.Logs.Println("[ERR][MIGRATION] error setting raw password for user", acc.Username, ":", rawPasswordError.Error()) + } + // Set OTP secret if err := SetUserOtpSecret(acc.Username, otp_secret); err != nil { logs.Logs.Println("[ERR][MIGRATION] error mirating OTP secret for user", acc.Username, ":", err.Error()) @@ -415,7 +404,7 @@ func InitReportDb() (*pgxpool.Pool, context.Context) { logs.Logs.Println("[WARN][DB] error in db connection:" + err.Error()) } - reportDbIsInitialized = loadReportSchema(dbpool, dbctx) + loadReportSchema(dbpool, dbctx) return dbpool, dbctx } @@ -425,18 +414,7 @@ func ReportInstance() (*pgxpool.Pool, context.Context) { dbpool, dbctx = InitReportDb() } - if !reportDbIsInitialized { - // check if 'units' table exists, if not call initialization - query := `SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'schema_name' - AND table_name = 'units' - )` - dbpool.QueryRow(dbctx, query).Scan(&reportDbIsInitialized) - if !reportDbIsInitialized { - reportDbIsInitialized = loadReportSchema(dbpool, dbctx) - } - } + return dbpool, dbctx } @@ -557,40 +535,49 @@ func MigrateUnitInfoFromFileToPostgres() []string { if _, err := os.Stat(configuration.Config.OpenVPNStatusDir); os.IsNotExist(err) { return ret } - files, err := os.ReadDir(configuration.Config.OpenVPNStatusDir) + files, err := os.ReadDir(configuration.Config.OpenVPNCCDDir) if err != nil { logs.Logs.Println("[WARNING][MIGRATION] error reading OpenVPN status directory: " + err.Error()) return ret } for _, file := range files { - if !file.IsDir() && strings.HasSuffix(file.Name(), ".info") { - filePath := configuration.Config.OpenVPNStatusDir + "/" + file.Name() - // read file, parse as JSON and then call AddUnit - data, err := os.ReadFile(filePath) - if err != nil { - logs.Logs.Println("[WARNING][MIGRATION] error reading file:", filePath, err.Error()) - continue - } - var info models.UnitInfo - if err := json.Unmarshal(data, &info); err != nil { - logs.Logs.Println("[WARNING][MIGRATION] error parsing JSON in file:", filePath, err.Error()) - continue - } - uuid := strings.TrimSuffix(file.Name(), ".info") + if !file.IsDir() { + vpnFile := configuration.Config.OpenVPNCCDDir + "/" + file.Name() + infoFile := configuration.Config.OpenVPNStatusDir + "/" + file.Name() + ".info" + uuid := file.Name() // ignore the error _ = AddUnit(uuid) - if err := SetUnitInfo(uuid, info); err != nil { - logs.Logs.Println("[WARNING][MIGRATION] error setting unit info for", uuid, ":", err.Error()) + if _, err := os.Stat(infoFile); err == nil { + // read file, parse as JSON and then call AddUnit + data, err := os.ReadFile(infoFile) + if err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error reading file:", infoFile, err.Error()) + continue + } + var info models.UnitInfo + if err := json.Unmarshal(data, &info); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error parsing JSON in file:", infoFile, err.Error()) + continue + } + if err := SetUnitInfo(uuid, info); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error setting unit info for", uuid, ":", err.Error()) + } + // remove the file + if err := os.Remove(infoFile); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error removing file:", infoFile, err.Error()) + } else { + logs.Logs.Println("[INFO][MIGRATION] removed file:", infoFile) + } } - // remove the file - if err := os.Remove(filePath); err != nil { - logs.Logs.Println("[WARNING][MIGRATION] error removing file:", filePath, err.Error()) + if err := os.Remove(vpnFile); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error removing file:", vpnFile, err.Error()) } else { - logs.Logs.Println("[INFO][MIGRATION] removed file:", filePath) + logs.Logs.Println("[INFO][MIGRATION] removed file:", vpnFile) } ret = append(ret, uuid) } } + logs.Logs.Println("[INFO][MIGRATION] migrated", len(ret), "units from file to Postgres") return ret } From 8e430b4b8d9200a244bc83120d7448f12aa8cb86 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 1 Jul 2025 11:07:16 +0200 Subject: [PATCH 18/39] fix(api): do not delete vpn config on migration Otherwise units will not be listed. --- api/storage/storage.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/api/storage/storage.go b/api/storage/storage.go index 11bcae71..0c840ee4 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -542,9 +542,18 @@ func MigrateUnitInfoFromFileToPostgres() []string { } for _, file := range files { if !file.IsDir() { - vpnFile := configuration.Config.OpenVPNCCDDir + "/" + file.Name() infoFile := configuration.Config.OpenVPNStatusDir + "/" + file.Name() + ".info" uuid := file.Name() + // Check if the unit already exists in Postgres + exists, err := UnitExists(uuid) + if err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error checking if unit exists in Postgres:", err.Error()) + continue + } + if exists { + logs.Logs.Println("[INFO][MIGRATION] unit", uuid, "already exists in Postgres, skipping migration") + continue + } // ignore the error _ = AddUnit(uuid) if _, err := os.Stat(infoFile); err == nil { @@ -569,11 +578,6 @@ func MigrateUnitInfoFromFileToPostgres() []string { logs.Logs.Println("[INFO][MIGRATION] removed file:", infoFile) } } - if err := os.Remove(vpnFile); err != nil { - logs.Logs.Println("[WARNING][MIGRATION] error removing file:", vpnFile, err.Error()) - } else { - logs.Logs.Println("[INFO][MIGRATION] removed file:", vpnFile) - } ret = append(ret, uuid) } } From c39afa9f05775a23040e44a31209f7469b4c7f82 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 1 Jul 2025 14:00:42 +0200 Subject: [PATCH 19/39] chore(proxy): support traefik v3 --- proxy/entrypoint.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/proxy/entrypoint.sh b/proxy/entrypoint.sh index 62cbf396..8b36beb8 100755 --- a/proxy/entrypoint.sh +++ b/proxy/entrypoint.sh @@ -85,6 +85,9 @@ providers: serversTransport: insecureSkipVerify: true +core: + defaultRuleSyntax: v2 + EOF cat << EOF > "${CONFIG_DIR}api.yaml" From bf97f6991fff22f38416a8dc8c881f9b0c809054 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 2 Jul 2025 14:08:40 +0200 Subject: [PATCH 20/39] feat(api): add CIDR utils --- api/main_test.go | 23 +++++++++++++++++++++++ api/utils/utils.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/api/main_test.go b/api/main_test.go index e54b5f8b..cbce0f85 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -867,6 +867,29 @@ func TestUnitAuthorization(t *testing.T) { assert.Equal(t, http.StatusForbidden, w.Code, "limited user should not be able to add a unit") } +func TestToCIDR(t *testing.T) { + tests := []struct { + ip string + mask string + want string + wantErr bool + }{ + {"192.168.1.10", "255.255.255.0", "192.168.1.10/24", false}, + {"172.16.5.4", "255.255.0.0", "172.16.5.4/16", false}, + {"192.168.1.10", "255.255.255.255", "192.168.1.10/32", false}, + {"192.168.1.10", "255.255.0", "", true}, // invalid mask + {"notanip", "255.255.255.0", "", true}, // invalid ip + } + for _, tt := range tests { + got := utils.ToCIDR(tt.ip, tt.mask) + if got == "" { + assert.Error(t, fmt.Errorf("invalid input"), "expected error for input: %v/%v", tt.ip, tt.mask) + } else { + assert.Equal(t, tt.want, got, "unexpected CIDR for input: %v/%v", tt.ip, tt.mask) + } + } +} + func setupRouter() *gin.Engine { // Singleton if router != nil { diff --git a/api/utils/utils.go b/api/utils/utils.go index e09f3f55..ad438bf9 100644 --- a/api/utils/utils.go +++ b/api/utils/utils.go @@ -12,6 +12,7 @@ package utils import ( "encoding/base64" "encoding/json" + "fmt" "net" "strconv" @@ -185,3 +186,35 @@ func DecryptAESGCM(ciphertext, key []byte) ([]byte, error) { } return plaintext, nil } + +// ToCIDR converts an IPv4 address and a netmask string into CIDR notation. +// It returns an empty string if the input IP or mask is invalid. +// For example, given "192.168.0.1" and "255.255.255.0", it returns "192.168.0.1/24". +func ToCIDR(ipStr, maskStr string) string { + // Parse the IP address string. + ip := net.ParseIP(ipStr) + if ip == nil { + return "" + } + // The function works with IPv4 addresses, so we ensure it's a 4-byte representation. + ipv4 := ip.To4() + if ipv4 == nil { + return "" + } + // Parse the netmask string as an IP address. + maskIP := net.ParseIP(maskStr) + if maskIP == nil { + return "" + } + // Convert the parsed netmask IP to a 4-byte representation. + maskIPv4 := maskIP.To4() + if maskIPv4 == nil { + return "" + } + // Create an IPMask type from the 4-byte mask. + mask := net.IPMask(maskIPv4) + // Get the prefix size (the number of leading '1's in the mask). + // The second return value is the total number of bits, which is always 32 for IPv4. + prefixSize, _ := mask.Size() + return fmt.Sprintf("%s/%d", ipStr, prefixSize) +} From 961e1c598385a2f6b58421c40408f405def8c806 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 2 Jul 2025 16:29:07 +0200 Subject: [PATCH 21/39] feat(api)!: move unit vpn info inside db --- api/main_test.go | 34 +++++- api/methods/unit.go | 156 ++----------------------- api/storage/report_schema.sql.tmpl | 2 + api/storage/storage.go | 175 ++++++++++++++++++++++++++++- api/storage/upgrade_schema.sql | 28 +---- api/utils/utils.go | 29 ++--- build.sh | 67 +++++++++++ 7 files changed, 297 insertions(+), 194 deletions(-) create mode 100755 build.sh diff --git a/api/main_test.go b/api/main_test.go index cbce0f85..8f09a550 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -428,7 +429,8 @@ func addUnit(t *testing.T) string { } // Manually add to the database: we can't call /units POST endpoint because // it requires the presence of easyrsa binary and configuration files - storage.AddUnit(unitID) + newIp := storage.GetFreeIP() + storage.AddUnit(unitID, newIp) return unitID } @@ -485,6 +487,10 @@ func TestAddInfoAndGetRemoteInfo(t *testing.T) { assert.Equal(t, float64(info.SSHPort), infoResp["ssh_port"]) assert.Equal(t, info.FQDN, infoResp["fqdn"]) assert.Equal(t, info.APIVersion, infoResp["api_version"]) + ipaddress := jsonResponse["data"].(map[string]interface{})["ipaddress"].(string) + assert.True(t, strings.HasPrefix(ipaddress, "172.21.0"), "ipaddress should start with 172.21.0, got: %v", ipaddress) + netmask := jsonResponse["data"].(map[string]interface{})["netmask"].(string) + assert.Equal(t, configuration.Config.OpenVPNNetmask, netmask, "OpenVPNNetmask should match the one in configuration, got: %v", netmask) } func TestForwardedAuthMiddleware(t *testing.T) { @@ -890,6 +896,32 @@ func TestToCIDR(t *testing.T) { } } +func TestToIpMask(t *testing.T) { + tests := []struct { + cidr string + wantIP string + wantNet string + wantErr bool + }{ + {"192.168.1.10/24", "192.168.1.10", "255.255.255.0", false}, + {"172.16.5.4/16", "172.16.5.4", "255.255.0.0", false}, + {"10.0.0.1/32", "10.0.0.1", "255.255.255.255", false}, + {"192.168.1.10/33", "", "", true}, // invalid mask + {"notanip/24", "", "", true}, // invalid ip + {"", "", "", true}, // empty input + } + for _, tt := range tests { + ip, mask := utils.ToIpMask(tt.cidr) + if tt.wantErr { + assert.Equal(t, "", ip, "expected empty ip for input: %v", tt.cidr) + assert.Equal(t, "", mask, "expected empty mask for input: %v", tt.cidr) + } else { + assert.Equal(t, tt.wantIP, ip, "unexpected ip for input: %v", tt.cidr) + assert.Equal(t, tt.wantNet, mask, "unexpected mask for input: %v", tt.cidr) + } + } +} + func setupRouter() *gin.Engine { // Singleton if router != nil { diff --git a/api/methods/unit.go b/api/methods/unit.go index 15551161..e3176959 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -16,7 +16,6 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "strconv" "strings" "time" @@ -56,12 +55,12 @@ func GetUnits(c *gin.Context) { continue } // read unit file - result, err := getUnitInfo(unit) - if err != nil { + result := storage.GetUnit(unit) + if result == nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, Message: "Can't get unit info for: " + unit, - Data: err.Error(), + Data: nil, })) } @@ -91,13 +90,13 @@ func GetUnit(c *gin.Context) { } // parse unit file - result, err := getUnitInfo(unitId) + result := storage.GetUnit(unitId) - if err != nil { + if result == nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, Message: "can't get unit info for: " + unitId, - Data: err.Error(), + Data: nil, })) } else { // return 200 OK with data @@ -244,41 +243,8 @@ func AddUnit(c *gin.Context) { return } - // get used ips - var usedIPs []string - - units, err := os.ReadDir(configuration.Config.OpenVPNCCDDir) - if err != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, - Message: "access CCD directory failed", - Data: err.Error(), - })) - return - } - - for _, e := range units { - // read unit file - unitFile, err := os.ReadFile(configuration.Config.OpenVPNCCDDir + "/" + e.Name()) - if err != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, - Message: "access CCD directory unit file failed", - Data: err.Error(), - })) - return - } - - // parse unit file - parts := strings.Split(string(unitFile), "\n") - parts = strings.Split(parts[0], " ") - - // append to array - usedIPs = append(usedIPs, parts[1]) - } - // get free ip of a network - freeIP := utils.GetFreeIP(configuration.Config.OpenVPNNetwork, configuration.Config.OpenVPNNetmask, usedIPs) + freeIP := storage.GetFreeIP() if freeIP == "" { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ @@ -334,7 +300,7 @@ func AddUnit(c *gin.Context) { } // create record inside units table - errCreate := storage.AddUnit(jsonRequest.UnitId) + errCreate := storage.AddUnit(jsonRequest.UnitId, freeIP) if errCreate != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, @@ -604,41 +570,11 @@ func DeleteUnit(c *gin.Context) { } func ListUnits() ([]string, error) { - // list unit name from files in OpenVPNCCDDir - units := []string{} - // list file in OpenVPNCCDDir - files, err := os.ReadDir(configuration.Config.OpenVPNCCDDir) - if err != nil { - return nil, err - } - - // loop through files - for _, file := range files { - units = append(units, file.Name()) - } - - return units, nil + return storage.ListUnits() } func ListConnectedUnits() ([]string, error) { - // list unit name from files in OpenVPNStatusDir - units := []string{} - - // list file in OpenVPNStatusDir - files, err := os.ReadDir(configuration.Config.OpenVPNStatusDir) - if err != nil { - return nil, err - } - - // loop through files - for _, file := range files { - - if filepath.Ext(file.Name()) == ".vpn" { - units = append(units, strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))) - } - } - - return units, nil + return storage.ListConnectedUnits() } func getUnitToken(unitId string) (string, string, error) { @@ -777,78 +713,6 @@ func GetRemoteInfo(unitId string) (models.UnitInfo, error) { return unitInfo.Data, nil } -func getUnitInfo(unitId string) (gin.H, error) { - // read ccd dir for unit - unitFile, err := readUnitFile(unitId) - if err != nil { - return gin.H{}, err - } - - // parse ccd dir file content - result := parseUnitFile(unitId, unitFile) - - // add info from unit - remote_info := storage.GetUnitInfo(unitId) - - if remote_info != nil { - result["info"] = remote_info - } else { - result["info"] = gin.H{} - } - - // add join code - result["join_code"] = utils.GetJoinCode(unitId) - - // add vpn info - vpn_info := getVPNInfo(unitId) - if vpn_info != nil { - result["vpn"] = vpn_info - } else { - result["vpn"] = gin.H{} - } - - return result, nil -} - -func getVPNInfo(unitId string) gin.H { - // read unit file - statusFile, err := os.ReadFile(configuration.Config.OpenVPNStatusDir + "/" + unitId + ".vpn") - if err != nil { - return nil - } - - // convert timestamp to int - time, _ := strconv.Atoi(string(statusFile)) - - // return vpn details - return gin.H{ - "connected_since": time, - } -} - -func readUnitFile(unitId string) ([]byte, error) { - // read unit file - unitFile, err := os.ReadFile(configuration.Config.OpenVPNCCDDir + "/" + unitId) - - // return results - return unitFile, err -} - -func parseUnitFile(unitId string, unitFile []byte) gin.H { - // parse unit file - parts := strings.Split(string(unitFile), "\n") - parts = strings.Split(parts[0], " ") - - // compose result - result := gin.H{ - "id": unitId, - "ipaddress": parts[1], - "netmask": parts[2], - } - - return result -} - func AddUnitGroup(c *gin.Context) { isAdmin := storage.IsAdmin(jwt.ExtractClaims(c)["id"].(string)) if !isAdmin { diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index 1c9fb295..f6aef09d 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS units ( uuid UUID PRIMARY KEY, name TEXT, info JSONB, + vpn_address TEXT, + vpn_connected_since TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/api/storage/storage.go b/api/storage/storage.go index 0c840ee4..e3f7844d 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -479,14 +479,14 @@ func SetUserRecoveryCodes(username string, codes []string) error { return err } -func AddUnit(uuid string) error { +func AddUnit(uuid string, ipaddr string) error { pgpool, pgctx := ReportInstance() // Try to insert the unit; if it already exists, return an error _, err := pgpool.Exec(pgctx, ` - INSERT INTO units (uuid, created_at, updated_at) - VALUES ($1, NOW(), NOW()) + INSERT INTO units (uuid, vpn_address, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) ON CONFLICT (uuid) DO NOTHING - `, uuid) + `, uuid, ipaddr) if err != nil { logs.Logs.Println("[ERR][STORAGE][ADD_UNIT] error in query execution:" + err.Error()) } @@ -528,6 +528,19 @@ func GetUnitInfo(uuid string) map[string]interface{} { return info } +func loadUnitIP(unitId string) string { + unitFile, err := os.ReadFile(configuration.Config.OpenVPNCCDDir + "/" + unitId) + if err != nil { + return "" + } + + // parse ccd dir file content + parts := strings.Split(string(unitFile), "\n") + parts = strings.Split(parts[0], " ") + + return parts[1] +} + func MigrateUnitInfoFromFileToPostgres() []string { ret := make([]string, 0) // Search for all *.info file inside Config.OpenVPNStatusDir @@ -542,8 +555,11 @@ func MigrateUnitInfoFromFileToPostgres() []string { } for _, file := range files { if !file.IsDir() { - infoFile := configuration.Config.OpenVPNStatusDir + "/" + file.Name() + ".info" uuid := file.Name() + infoFile := configuration.Config.OpenVPNStatusDir + "/" + uuid + ".info" + vpnFile := configuration.Config.OpenVPNCCDDir + "/" + uuid + ".vpn" + ipaddr := loadUnitIP(uuid) + // Check if the unit already exists in Postgres exists, err := UnitExists(uuid) if err != nil { @@ -555,7 +571,20 @@ func MigrateUnitInfoFromFileToPostgres() []string { continue } // ignore the error - _ = AddUnit(uuid) + addUnitErr := AddUnit(uuid, ipaddr) + if addUnitErr != nil { + logs.Logs.Println("[WARNING][MIGRATION] error adding unit to Postgres:", uuid, addUnitErr.Error()) + continue + } + connected_since := 0 + statusFile, err := os.ReadFile(vpnFile) + if err == nil { + connected_since, _ = strconv.Atoi(string(statusFile)) + } + if connected_since > 0 { + // Update the unit with the connected_since timestamp + UpdateUnitVpnStatus(uuid, connected_since) + } if _, err := os.Stat(infoFile); err == nil { // read file, parse as JSON and then call AddUnit data, err := os.ReadFile(infoFile) @@ -813,3 +842,137 @@ func ReloadACLs() { adminUsers = admins logs.Logs.Println("[INFO][STORAGE][RELOAD_ADMIN_USERS] admin users reloaded successfully") } + +func GetFreeIP() string { + // get all ips + IPs, _ := utils.ListIPs(configuration.Config.OpenVPNNetwork, configuration.Config.OpenVPNNetmask) + // remove first ip used for tun + IPs = IPs[1:] + + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, "SELECT vpn_address FROM units WHERE vpn_address IS NOT NULL") + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_FREE_IP] error in query execution:" + err.Error()) + return "" + } + defer rows.Close() + + usedIPs := make([]string, 0) + for rows.Next() { + var ip string + if err := rows.Scan(&ip); err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_FREE_IP] error in row scan: " + err.Error()) + continue + } + usedIPs = append(usedIPs, ip) + } + // usedIPs now contains all used IP addresses from the units table + // loop all IPs + for _, ip := range IPs { + if !utils.Contains(ip, usedIPs) { + return ip + } + } + return "" +} + +func ListUnits() ([]string, error) { + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, "SELECT uuid FROM units") + if err != nil { + logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in query execution:" + err.Error()) + return nil, err + } + defer rows.Close() + + var uuids []string + for rows.Next() { + var uuid string + if err := rows.Scan(&uuid); err != nil { + logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in row scan: " + err.Error()) + continue + } + uuids = append(uuids, uuid) + } + return uuids, nil +} + +func ListConnectedUnits() ([]string, error) { + pgpool, pgctx := ReportInstance() + rows, err := pgpool.Query(pgctx, "SELECT uuid FROM units WHERE vpn_connected_since IS NOT NULL") + if err != nil { + logs.Logs.Println("[INFO][STORAGE][LIST_CONNECTED_UNITS] error in query execution:" + err.Error()) + return nil, err + } + defer rows.Close() + + var uuids []string + for rows.Next() { + var uuid string + if err := rows.Scan(&uuid); err != nil { + logs.Logs.Println("[ERR][STORAGE][LIST_CONNECTED_UNITS] error in row scan: " + err.Error()) + continue + } + uuids = append(uuids, uuid) + } + return uuids, nil +} + +func UpdateUnitVpnStatus(uuid string, connectedSince int) error { + pgpool, pgctx := ReportInstance() + // Convert connectedSince (seconds since epoch) to time.Time + connectedTime := time.Unix(int64(connectedSince), 0) + _, err := pgpool.Exec(pgctx, ` + UPDATE units + SET vpn_connected_since = $1, updated_at = NOW() + WHERE uuid = $2 + `, connectedTime, uuid) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UPDATE_UNIT_VPN_STATUS] error in query execution:" + err.Error()) + return err + } + return nil +} + +func GetUnit(uuid string) map[string]interface{} { + pgpool, pgctx := ReportInstance() + var ( + ipaddress sql.NullString + infoStr sql.NullString + connectedSince sql.NullTime + ) + err := pgpool.QueryRow(pgctx, ` + SELECT vpn_address, info::text, vpn_connected_since + FROM units + WHERE uuid = $1 + `, uuid).Scan(&ipaddress, &infoStr, &connectedSince) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT] error in query execution:" + err.Error()) + return nil + } + + result := make(map[string]interface{}) + result["id"] = uuid + result["ipaddress"] = ipaddress.String + result["netmask"] = configuration.Config.OpenVPNNetmask + + var info map[string]interface{} + if infoStr.Valid && infoStr.String != "" { + if err := json.Unmarshal([]byte(infoStr.String), &info); err == nil { + result["info"] = info + } else { + result["info"] = map[string]interface{}{} + } + } else { + result["info"] = map[string]interface{}{} + } + + result["join_code"] = utils.GetJoinCode(uuid) + if connectedSince.Valid { + result["connected_since"] = connectedSince.Time.Unix() + } else { + result["connected_since"] = nil + } + + return result +} diff --git a/api/storage/upgrade_schema.sql b/api/storage/upgrade_schema.sql index 6ffe6d73..78d1521f 100644 --- a/api/storage/upgrade_schema.sql +++ b/api/storage/upgrade_schema.sql @@ -7,28 +7,8 @@ * author: Giacomo Sanchietti */ -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name='units' - AND column_name='info' - ) THEN - ALTER TABLE units ADD COLUMN info JSONB; - END IF; -END; -$$; -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name='units' - AND column_name='updated_at' - ) THEN - ALTER TABLE units ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; - END IF; -END; -$$; \ No newline at end of file +ALTER TABLE units ADD COLUMN IF NOT EXISTS info JSONB; +ALTER TABLE units ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE units ADD COLUMN IF NOT EXISTS vpn_address TEXT; +ALTER TABLE units ADD COLUMN IF NOT EXISTS vpn_connected_since TIMESTAMP NULL; \ No newline at end of file diff --git a/api/utils/utils.go b/api/utils/utils.go index ad438bf9..b225eb67 100644 --- a/api/utils/utils.go +++ b/api/utils/utils.go @@ -44,23 +44,6 @@ func Contains(a string, values []string) bool { return false } -func GetFreeIP(ip string, netmask string, usedIPs []string) string { - // get all ips - IPs, _ := ListIPs(ip, netmask) - - // remove first ip used for tun - IPs = IPs[1:] - - // loop all IPs - for _, ip := range IPs { - if !Contains(ip, usedIPs) { - return ip - } - } - - return "" -} - func ListIPs(ipArg string, netmaskArg string) ([]string, error) { // convert netmask to prefix prefixMask, _ := net.IPMask(net.ParseIP(netmaskArg).To4()).Size() @@ -218,3 +201,15 @@ func ToCIDR(ipStr, maskStr string) string { prefixSize, _ := mask.Size() return fmt.Sprintf("%s/%d", ipStr, prefixSize) } + +// ToIpMask takes an IP address in CIDR notation (e.g., "192.168.100.2/24") +// and returns the IP address and its corresponding netmask string (e.g., "255.255.255.0"). +func ToIpMask(cidr string) (string, string) { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return "", "" + } + mask := ipnet.Mask + netmask := fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3]) + return ip.String(), netmask +} diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..b936121a --- /dev/null +++ b/build.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -e + +repobase="ghcr.io/nethserver" + +images=() +container=$(buildah from docker.io/debian:bookworm) +ui_version="1.0.6" + +trap "buildah rm ${container} ${container_api} ${container_proxy}" EXIT + +echo "Installing build dependencies..." +buildah run ${container} apt-get update +buildah run ${container} apt-get install openvpn easy-rsa -y + +echo "Setup image" +buildah add "${container}" vpn/ip /sbin/ip +buildah add "${container}" vpn/controller-auth /usr/local/bin/controller-auth +buildah add "${container}" vpn/handle-connection /usr/local/bin/handle-connection +buildah add "${container}" vpn/entrypoint.sh /entrypoint.sh +buildah config --entrypoint='["/entrypoint.sh"]' --cmd='["/usr/sbin/openvpn", "/etc/openvpn/server.conf"]' ${container} +buildah commit "${container}" "${repobase}/nethsecurity-vpn" +images+=("${repobase}/nethsecurity-vpn") + +container_api=$(buildah from docker.io/alpine:3.16) +buildah run ${container_api} apk add --no-cache go easy-rsa openssh sqlite +buildah run ${container_api} mkdir /nethsecurity-api +buildah add "${container_api}" api/ /nethsecurity-api/ +buildah config --workingdir /nethsecurity-api ${container_api} +buildah config --env GOOS=linux --env GOARCH=amd64 --env CGO_ENABLED=1 ${container_api} +buildah run ${container_api} go build -ldflags='-extldflags=-static' -tags sqlite_omit_load_extension +buildah run ${container_api} rm -rf root/go +buildah run ${container_api} apk del --no-cache go +buildah add "${container_api}" api/entrypoint.sh /entrypoint.sh +buildah config --entrypoint='["/entrypoint.sh"]' --cmd='["./api"]' ${container_api} +buildah commit "${container_api}" "${repobase}/nethsecurity-api" +images+=("${repobase}/nethsecurity-api") + +container_proxy=$(buildah from docker.io/library/traefik:v2.6) +buildah add "${container_proxy}" proxy/entrypoint.sh /entrypoint.sh +buildah config --entrypoint='["/entrypoint.sh"]' --cmd='["/usr/local/bin/traefik", "--configFile=/config.yaml"]' ${container_proxy} +buildah commit "${container_proxy}" "${repobase}/nethsecurity-proxy" +images+=("${repobase}/nethsecurity-proxy") + +container_ui=$(buildah from docker.io/alpine:3.17) +buildah run ${container_ui} apk add --no-cache lighttpd git nodejs npm +buildah run ${container_ui} git clone --depth 1 --branch ${ui_version} https://github.com/NethServer/nethsecurity-ui.git +buildah config --workingdir /nethsecurity-ui ${container_ui} +buildah run ${container_ui} sh -c "sed -i 's/standalone/controller/g' .env.production" +buildah run ${container_ui} sh -c "npm ci && npm run build" +buildah run ${container_ui} sh -c "cp -r dist/* /var/www/localhost/htdocs/" +buildah add ${container_ui} ui/entrypoint.sh /entrypoint.sh +buildah run ${container_ui} sh -c "rm -rf /nethsecurity-ui" +buildah run ${container_ui} apk del --no-cache git nodejs npm +buildah config --workingdir / ${container_ui} +buildah config --entrypoint='["/entrypoint.sh"]' ${container_ui} +buildah commit ${container_ui} "${repobase}/nethsecurity-ui" +images+=("${repobase}/nethsecurity-ui") + +if [[ -n "${CI}" ]]; then + # Set output value for Github Actions + printf "::set-output name=images::%s\n" "${images[*]}" +else + printf "Publish the images with:\n\n" + for image in "${images[@]}"; do printf " buildah push %s docker://%s:latest\n" "${image}" "${image}" ; done + printf "\n" +fi From eb8d6e4ee660fd1a7f9f612300416db12fd0d6e7 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 3 Jul 2025 08:29:54 +0200 Subject: [PATCH 22/39] feat(vpn): write vpn info inside db --- api/entrypoint.sh | 3 ++ api/methods/unit.go | 39 +++++++------- api/storage/storage.go | 110 +++++++++++++++++++++++++++------------ vpn/Containerfile | 3 +- vpn/handle-connection | 5 +- vpn/handle-disconnection | 5 +- 6 files changed, 105 insertions(+), 60 deletions(-) diff --git a/api/entrypoint.sh b/api/entrypoint.sh index 9d75317a..08373c1a 100755 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -26,4 +26,7 @@ while [ ! -e "$socket" ]; do fi done +# Create database config for OpenVPN hooks +echo REPORT_DB_URI=$REPORT_DB_URI > /etc/openvpn/db.env + exec "$@" diff --git a/api/methods/unit.go b/api/methods/unit.go index e3176959..77a7b480 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -38,7 +38,7 @@ func GetUnits(c *gin.Context) { user := jwt.ExtractClaims(c)["id"].(string) // list file in OpenVPNCCDDir - units, err := ListUnits() + units, err := storage.ListUnits() if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, @@ -51,21 +51,12 @@ func GetUnits(c *gin.Context) { // loop through units var results []gin.H for _, unit := range units { - if !UserCanAccessUnit(user, unit) { + unitId, ok := unit["id"].(string) + if !ok || !UserCanAccessUnit(user, unitId) { continue } - // read unit file - result := storage.GetUnit(unit) - if result == nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, - Message: "Can't get unit info for: " + unit, - Data: nil, - })) - } - // append to array - results = append(results, result) + results = append(results, unit) } // return 200 OK with data @@ -90,13 +81,13 @@ func GetUnit(c *gin.Context) { } // parse unit file - result := storage.GetUnit(unitId) + result, err := storage.GetUnit(unitId) - if result == nil { + if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, Message: "can't get unit info for: " + unitId, - Data: nil, + Data: err.Error(), })) } else { // return 200 OK with data @@ -214,7 +205,7 @@ func AddUnit(c *gin.Context) { // if the controller does not have a subscription, limit the number of units to 3 if !configuration.Config.ValidSubscription { - units, err := ListUnits() + units, err := storage.ListUnits() if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, @@ -561,6 +552,16 @@ func DeleteUnit(c *gin.Context) { } } + deleteError := storage.DeleteUnit(unitId) + if deleteError != nil { + c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ + Code: 500, + Message: "error in deletion unit record for: " + unitId, + Data: deleteError.Error(), + })) + return + } + // return 200 OK c.JSON(http.StatusOK, structs.Map(response.StatusOK{ Code: 200, @@ -569,10 +570,6 @@ func DeleteUnit(c *gin.Context) { })) } -func ListUnits() ([]string, error) { - return storage.ListUnits() -} - func ListConnectedUnits() ([]string, error) { return storage.ListConnectedUnits() } diff --git a/api/storage/storage.go b/api/storage/storage.go index e3f7844d..2102d25a 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -876,25 +876,54 @@ func GetFreeIP() string { return "" } -func ListUnits() ([]string, error) { +func ListUnits() ([]map[string]interface{}, error) { + pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, "SELECT uuid FROM units") + rows, err := pgpool.Query(pgctx, "SELECT uuid, vpn_address, info::text, vpn_connected_since FROM units ORDER BY created_at ASC") if err != nil { logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in query execution:" + err.Error()) return nil, err } defer rows.Close() - var uuids []string + units := make([]map[string]interface{}, 0) for rows.Next() { - var uuid string - if err := rows.Scan(&uuid); err != nil { + var uuid sql.NullString + var ipaddress sql.NullString + var infoStr sql.NullString + var connectedSince sql.NullTime + var info map[string]interface{} + vpn_info := make(map[string]interface{}) + unit := make(map[string]interface{}) + + if err := rows.Scan(&uuid, &ipaddress, &infoStr, &connectedSince); err != nil { logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in row scan: " + err.Error()) continue } - uuids = append(uuids, uuid) + + unit["id"] = uuid.String + unit["ipaddress"] = ipaddress.String + unit["netmask"] = configuration.Config.OpenVPNNetmask + unit["vpn"] = vpn_info + + if infoStr.Valid && infoStr.String != "" { + if err := json.Unmarshal([]byte(infoStr.String), &info); err == nil { + unit["info"] = info + } else { + unit["info"] = map[string]interface{}{} + } + } else { + unit["info"] = map[string]interface{}{} + } + + unit["join_code"] = utils.GetJoinCode(uuid.String) + if connectedSince.Valid { + vpn_info["connected_since"] = connectedSince.Time.Unix() + } + + units = append(units, unit) } - return uuids, nil + return units, nil } func ListConnectedUnits() ([]string, error) { @@ -934,45 +963,60 @@ func UpdateUnitVpnStatus(uuid string, connectedSince int) error { return nil } -func GetUnit(uuid string) map[string]interface{} { +func GetUnit(uuid string) (map[string]interface{}, error) { pgpool, pgctx := ReportInstance() - var ( - ipaddress sql.NullString - infoStr sql.NullString - connectedSince sql.NullTime - ) - err := pgpool.QueryRow(pgctx, ` - SELECT vpn_address, info::text, vpn_connected_since - FROM units - WHERE uuid = $1 - `, uuid).Scan(&ipaddress, &infoStr, &connectedSince) - if err != nil { + row := pgpool.QueryRow(pgctx, "SELECT uuid, vpn_address, info::text, vpn_connected_since FROM units WHERE uuid = $1", uuid) + + var unit map[string]interface{} + var ipaddress sql.NullString + var infoStr sql.NullString + var connectedSince sql.NullTime + + if err := row.Scan(&uuid, &ipaddress, &infoStr, &connectedSince); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_UNIT] error in query execution:" + err.Error()) - return nil + return nil, err } - result := make(map[string]interface{}) - result["id"] = uuid - result["ipaddress"] = ipaddress.String - result["netmask"] = configuration.Config.OpenVPNNetmask + unit = make(map[string]interface{}) + unit["id"] = uuid + unit["ipaddress"] = ipaddress.String + unit["netmask"] = configuration.Config.OpenVPNNetmask + vpn_info := make(map[string]interface{}) + vpn_info["connected_since"] = 0 - var info map[string]interface{} if infoStr.Valid && infoStr.String != "" { + var info map[string]interface{} if err := json.Unmarshal([]byte(infoStr.String), &info); err == nil { - result["info"] = info + unit["info"] = info } else { - result["info"] = map[string]interface{}{} + unit["info"] = map[string]interface{}{} } } else { - result["info"] = map[string]interface{}{} + unit["info"] = map[string]interface{}{} } - result["join_code"] = utils.GetJoinCode(uuid) if connectedSince.Valid { - result["connected_since"] = connectedSince.Time.Unix() - } else { - result["connected_since"] = nil + vpn_info["connected_since"] = connectedSince.Time.Unix() } - return result + unit["vpn"] = vpn_info + unit["join_code"] = utils.GetJoinCode(uuid) + + return unit, nil +} + +func DeleteUnit(uuid string) error { + pgpool, pgctx := ReportInstance() + // Delete the unit from the database + res, err := pgpool.Exec(pgctx, "DELETE FROM units WHERE uuid = $1", uuid) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][DELETE_UNIT] error in query execution:" + err.Error()) + return err + } + if res.RowsAffected() == 0 { + logs.Logs.Println("[WARN][STORAGE][DELETE_UNIT] no unit deleted with uuid " + uuid) + return fmt.Errorf("no unit deleted with uuid %s", uuid) + } + + return nil } diff --git a/vpn/Containerfile b/vpn/Containerfile index 61963011..a890712f 100644 --- a/vpn/Containerfile +++ b/vpn/Containerfile @@ -1,7 +1,8 @@ FROM docker.io/alpine:3.16.9 AS dist RUN apk add --no-cache \ openvpn=2.5.6-r1 \ - easy-rsa + easy-rsa \ + postgresql-client COPY ip /sbin/ip COPY controller-auth /usr/local/bin/controller-auth COPY handle-connection /usr/local/bin/handle-connection diff --git a/vpn/handle-connection b/vpn/handle-connection index 8ec3f801..ab0d5d8c 100755 --- a/vpn/handle-connection +++ b/vpn/handle-connection @@ -30,5 +30,6 @@ http: - "/$username" EOF -echo -n "$ifconfig_pool_remote_ip" > /etc/openvpn/clients/$username -echo -n $(date +%s) > /etc/openvpn/status/$username.vpn \ No newline at end of file +source /etc/openvpn/db.env + +/usr/bin/psql $REPORT_DB_URI -c "UPDATE units SET vpn_connected_since = NOW() WHERE uuid = '$common_name';" diff --git a/vpn/handle-disconnection b/vpn/handle-disconnection index 655443b0..a193b8ba 100755 --- a/vpn/handle-disconnection +++ b/vpn/handle-disconnection @@ -1,5 +1,4 @@ #!/bin/sh -# Remove openvpn instance -username=$common_name -rm -f /etc/openvpn/status/$username.vpn +source /etc/openvpn/db.env +/usr/bin/psql $REPORT_DB_URI -c "UPDATE units SET vpn_connected_since = NULL WHERE uuid = '$common_name';" From 5d7b2cff8502aec69e11cd5ebdbd16749c14bfd4 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 3 Jul 2025 11:56:43 +0200 Subject: [PATCH 23/39] feat(api)!: move unit credentials to db Unit credentials are now encrypted This change is part of a refactor to improve security. --- api/configuration/configuration.go | 2 +- api/entrypoint.sh | 1 - api/main_test.go | 15 ++++-- api/methods/unit.go | 53 ++++++++++--------- api/storage/report_schema.sql.tmpl | 6 +++ api/storage/storage.go | 85 ++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 30 deletions(-) diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 47bf9a23..2f90e05a 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -40,7 +40,7 @@ type Configuration struct { RegistrationToken string `json:"registration_token"` TokensDir string `json:"tokens_dir"` - CredentialsDir string `json:"credentials_dir"` + CredentialsDir string `json:"credentials_dir"` // Deprecated: it can be removed in the future DataDir string `json:"data_dir"` Issuer2FA string `json:"issuer_2fa"` SecretsDir string `json:"secrets_dir"` // Deprecated: it can be removed in the future diff --git a/api/entrypoint.sh b/api/entrypoint.sh index 08373c1a..5ae38be0 100755 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -2,7 +2,6 @@ mkdir -p /etc/openvpn/sockets mkdir -p /nethsecurity-api/tokens -mkdir -p /nethsecurity-api/credentials cd /nethsecurity-api diff --git a/api/main_test.go b/api/main_test.go index 8f09a550..bf709c49 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -275,7 +275,8 @@ func TestMainEndpoints(t *testing.T) { } // make sure configuration.Config.OpenVPNPKIDir does not exists os.RemoveAll(configuration.Config.OpenVPNPKIDir) - body := `{"unit_id": "11", "username": "aa", "unit_name": "bbb", "password": "ccc"}` + unitID := "88860838-63bd-4717-a6c3-cbc351010843" + body := `{"unit_id": "` + unitID + `", "username": "myuser", "unit_name": "myname", "password": "mypassword"}` req, _ := http.NewRequest("POST", "/units/register", bytes.NewBuffer([]byte(body))) req.Header.Set("Content-Type", "application/json") req.Header.Set("RegistrationToken", "1234") @@ -293,10 +294,10 @@ func TestMainEndpoints(t *testing.T) { } } // create fake certificate file and key file - if _, err := os.Create(configuration.Config.OpenVPNPKIDir + "/issued/" + "11" + ".crt"); err != nil { + if _, err := os.Create(configuration.Config.OpenVPNPKIDir + "/issued/" + unitID + ".crt"); err != nil { t.Fatalf("failed to create file: %v", err) } - if _, err := os.Create(configuration.Config.OpenVPNPKIDir + "/private/" + "11" + ".key"); err != nil { + if _, err := os.Create(configuration.Config.OpenVPNPKIDir + "/private/" + unitID + ".key"); err != nil { t.Fatalf("failed to create file: %v", err) } // create face ca.crt file @@ -308,7 +309,13 @@ func TestMainEndpoints(t *testing.T) { req.Header.Set("RegistrationToken", "1234") w = httptest.NewRecorder() router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // Check password retrieval at lower level + user, pass, err := storage.GetUnitCredentials(unitID) // should return empty credentials + assert.NoError(t, err, "GetUnitCredentials should not return an error") + assert.Equal(t, "myuser", user) + assert.Equal(t, "mypassword", pass) }) t.Run("TestNoRoute", func(t *testing.T) { diff --git a/api/methods/unit.go b/api/methods/unit.go index 77a7b480..597cf14a 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -126,9 +126,21 @@ func GetToken(c *gin.Context) { } func GetUnitInfo(c *gin.Context) { + // extract user from JWT claims + user := jwt.ExtractClaims(c)["id"].(string) + // get unit id unitId := c.Param("unit_id") + if !UserCanAccessUnit(user, unitId) { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "user does not have access to this unit", + Data: nil, + })) + return + } + // get unit info and store it info, err := GetRemoteInfo(unitId) @@ -427,32 +439,21 @@ func RegisterUnit(c *gin.Context) { "vpn_address": vpnAddress, } - // read credentials from request - username := jsonRequest.Username - password := jsonRequest.Password - - // read credentials from file - var credentials models.LoginRequest - jsonString, errRead := os.ReadFile(configuration.Config.CredentialsDir + "/" + jsonRequest.UnitId) + // read credentials from database + curUsername, _, errRead := storage.GetUnitCredentials(jsonRequest.UnitId) + var errWrite error // credentials exists, update only if username matches if errRead == nil { - // convert json string to struct - json.Unmarshal(jsonString, &credentials) - - // check username - if credentials.Username == username { - credentials.Password = password + if curUsername == jsonRequest.Username { + errWrite = storage.SetUnitCredentials(jsonRequest.UnitId, curUsername, jsonRequest.Password) } } else { // create credentials - credentials.Username = username - credentials.Password = password + errWrite = storage.SetUnitCredentials(jsonRequest.UnitId, jsonRequest.Username, jsonRequest.Password) } - // write new credentials - newJsonString, _ := json.Marshal(credentials) - errWrite := os.WriteFile(configuration.Config.CredentialsDir+"/"+jsonRequest.UnitId, newJsonString, 0644) + // save new credentials if errWrite != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, @@ -577,19 +578,23 @@ func ListConnectedUnits() ([]string, error) { func getUnitToken(unitId string) (string, string, error) { // read credentials - var credentials models.LoginRequest - body, err := os.ReadFile(configuration.Config.CredentialsDir + "/" + unitId) + username, password, err := storage.GetUnitCredentials(unitId) if err != nil { - return "", "", errors.New("cannot open credentials file for: " + unitId) + return "", "", errors.New("cannot read credentials for: " + unitId) } - // convert json string to struct - json.Unmarshal(body, &credentials) - // compose request URL postURL := configuration.Config.ProxyProtocol + configuration.Config.ProxyHost + ":" + configuration.Config.ProxyPort + "/" + unitId + configuration.Config.LoginEndpoint // create request action + credentials := models.LoginRequest{ + Username: username, + Password: password, + } + body, err := json.Marshal(credentials) + if err != nil { + return "", "", errors.New("cannot marshal credentials for: " + unitId) + } r, err := http.NewRequest("POST", postURL, bytes.NewBuffer(body)) if err != nil { return "", "", errors.New("cannot make request for: " + unitId) diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index f6aef09d..6c9623ef 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS units ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS unit_credentials ( + uuid UUID PRIMARY KEY, + username TEXT, + password TEXT +); + CREATE TABLE IF NOT EXISTS openvpn_config ( uuid UUID NOT NULL references units(uuid), instance TEXT NOT NULL, diff --git a/api/storage/storage.go b/api/storage/storage.go index 2102d25a..1e70699d 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -60,6 +60,9 @@ func Init() *pgxpool.Pool { // Migrate users from SQLite to Postgres if needed MigrateUsersFromSqliteToPostgres(migrated_units) + // Migrate unit credentials from file to Postgres + MigrateUnitCredentialsFromFileToPostgres() + ReloadACLs() // Initialize PostgreSQL connection @@ -1018,5 +1021,87 @@ func DeleteUnit(uuid string) error { return fmt.Errorf("no unit deleted with uuid %s", uuid) } + // Also delete credentials for this unit + _, err = pgpool.Exec(pgctx, "DELETE FROM unit_credentials WHERE uuid = $1", uuid) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][DELETE_UNIT] error deleting unit credentials:" + err.Error()) + return err + } + return nil } + +func GetUnitCredentials(uuid string) (string, string, error) { + pgpool, pgctx := ReportInstance() + var username, password string + err := pgpool.QueryRow(pgctx, "SELECT username, password FROM unit_credentials WHERE uuid = $1::uuid", uuid).Scan(&username, &password) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][GET_UNIT_CREDENTIALS] error in query execution:" + err.Error()) + return "", "", err + } + + decrypted, err := utils.DecryptAESGCMFromString(password, []byte(configuration.Config.EncryptionKey)) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][DECRYPT_UNIT_CREDENTIALS] error in decryption:" + err.Error()) + return "", "", err + } + + return username, string(decrypted), nil +} + +func SetUnitCredentials(uuid string, username string, password string) error { + encrypted, err := utils.EncryptAESGCMToString([]byte(password), []byte(configuration.Config.EncryptionKey)) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][ENCRYPT_UNIT_CREDENTIALS] error in encryption:" + err.Error()) + return err + } + + pgpool, pgctx := ReportInstance() + // Update the account with the new credentials + _, err = pgpool.Exec(pgctx, ` + INSERT INTO unit_credentials (uuid, username, password) + VALUES ($1, $2, $3) + ON CONFLICT (uuid) DO UPDATE + SET username = EXCLUDED.username, password = EXCLUDED.password + `, uuid, username, encrypted) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][SET_UNIT_CREDENTIALS] error in query execution:" + err.Error()) + return err + } + return nil +} + +func MigrateUnitCredentialsFromFileToPostgres() { + migrated := 0 + files, err := os.ReadDir(configuration.Config.CredentialsDir) + if err != nil { + logs.Logs.Println("[INFO][MIGRATION] credentials directory does not exists. Skipping migration.") + return + } + for _, file := range files { + if file.IsDir() { + continue + } + unitID := file.Name() + credPath := configuration.Config.CredentialsDir + "/" + unitID + jsonString, errRead := os.ReadFile(credPath) + if errRead != nil { + logs.Logs.Println("[WARNING][MIGRATION] error reading credentials file:", credPath, errRead.Error()) + continue + } + var credentials models.LoginRequest + if err := json.Unmarshal(jsonString, &credentials); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error parsing credentials JSON in file:", credPath, err.Error()) + continue + } + if err := SetUnitCredentials(unitID, credentials.Username, credentials.Password); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error setting credentials for unit", unitID, ":", err.Error()) + continue + } + if err := os.Remove(credPath); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error removing credentials file:", credPath, err.Error()) + } + migrated++ + } + logs.Logs.Printf("[INFO][MIGRATION] migrated %d unit credentials from file to Postgres\n", migrated) +} From 511f371be567c2c31cd02a471f6f7d64b31cbeb0 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 3 Jul 2025 13:59:47 +0200 Subject: [PATCH 24/39] fix(api): fix static lint warning --- api/storage/storage.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/storage/storage.go b/api/storage/storage.go index 1e70699d..9ac7c46d 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -55,10 +55,10 @@ func Init() *pgxpool.Pool { dbpool, dbctx = InitReportDb() // Migrate unit info from file to Postgres if needed - migrated_units := MigrateUnitInfoFromFileToPostgres() + migratedUnits := MigrateUnitInfoFromFileToPostgres() // Migrate users from SQLite to Postgres if needed - MigrateUsersFromSqliteToPostgres(migrated_units) + MigrateUsersFromSqliteToPostgres(migratedUnits) // Migrate unit credentials from file to Postgres MigrateUnitCredentialsFromFileToPostgres() @@ -163,7 +163,7 @@ func MigrateUsersFromSqliteToPostgres(units []string) { groupID, groupErr = AddUnitGroup(group) // Create a unit group for the migrated units if groupErr != nil { - logs.Logs.Println("[ERR][MIGRATION] error creating unit group in Postgres: " + err.Error()) + logs.Logs.Println("[ERR][MIGRATION] error creating unit group in Postgres: " + groupErr.Error()) } } From c288a14dede20f8039b38795a9a98cc5e776c32a Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 3 Jul 2025 14:51:13 +0200 Subject: [PATCH 25/39] feat(api)!: move JWT tokens to RAM This change will prevent that someone can access valid tokens looking inside the filesytem. On NS8, the module did not use a volume for the token directory, so the behavior does not change: on controller restart, all sessions are destroyed. This change is part of a refactor to improve security. --- api/README.md | 1 - api/configuration/configuration.go | 7 --- api/entrypoint.sh | 1 - api/main_test.go | 27 ++++++------ api/methods/auth.go | 69 +++++++++++++++--------------- 5 files changed, 50 insertions(+), 55 deletions(-) diff --git a/api/README.md b/api/README.md index df7e8c58..cb72bc9c 100644 --- a/api/README.md +++ b/api/README.md @@ -15,7 +15,6 @@ CGO_ENABLED=0 go build - `SECRET_JWT`: secret to sing JWT tokens - `REGISTRATION_TOKEN`: secret token used to register units -- `TOKENS_DIR`: directory to save authenticated tokens - `CREDENTIALS_DIR`: directory to save credentials of connected units - `PROMTAIL_ADDRESS`: promtail address diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 2f90e05a..90793a2d 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -39,7 +39,6 @@ type Configuration struct { SensitiveList []string `json:"sensitive_list"` RegistrationToken string `json:"registration_token"` - TokensDir string `json:"tokens_dir"` CredentialsDir string `json:"credentials_dir"` // Deprecated: it can be removed in the future DataDir string `json:"data_dir"` Issuer2FA string `json:"issuer_2fa"` @@ -118,12 +117,6 @@ func Init() { os.Exit(1) } - if os.Getenv("TOKENS_DIR") != "" { - Config.TokensDir = os.Getenv("TOKENS_DIR") - } else { - logs.Logs.Println("[CRITICAL][ENV] TOKENS_DIR variable is empty") - os.Exit(1) - } if os.Getenv("CREDENTIALS_DIR") != "" { Config.CredentialsDir = os.Getenv("CREDENTIALS_DIR") } else { diff --git a/api/entrypoint.sh b/api/entrypoint.sh index 5ae38be0..587f57f4 100755 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -1,7 +1,6 @@ #!/bin/sh mkdir -p /etc/openvpn/sockets -mkdir -p /nethsecurity-api/tokens cd /nethsecurity-api diff --git a/api/main_test.go b/api/main_test.go index bf709c49..653f353d 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "crypto/rand" "encoding/json" "fmt" "net/http" @@ -11,10 +12,10 @@ import ( "testing" "time" - "crypto/rand" mathrand "math/rand" "github.com/NethServer/nethsecurity-controller/api/configuration" + "github.com/NethServer/nethsecurity-controller/api/methods" "github.com/NethServer/nethsecurity-controller/api/models" "github.com/NethServer/nethsecurity-controller/api/storage" "github.com/NethServer/nethsecurity-controller/api/utils" @@ -165,6 +166,7 @@ func TestMainEndpoints(t *testing.T) { token = jsonResponse["token"].(string) assert.Equal(t, http.StatusOK, w.Code) assert.NotEmpty(t, token) + assert.True(t, methods.CheckTokenValidation("admin", token)) }) t.Run("TestRefreshEndpoint", func(t *testing.T) { @@ -189,16 +191,25 @@ func TestMainEndpoints(t *testing.T) { req.Header.Set("Authorization", "Bearer "+token) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + assert.False(t, methods.CheckTokenValidation("admin", token)) }) t.Run("TestGetAccountsEndpoint", func(t *testing.T) { + // Login again + var jsonResponse map[string]interface{} w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/accounts", nil) + body := `{"username": "admin", "password": "admin"}` + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer([]byte(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + json.NewDecoder(w.Body).Decode(&jsonResponse) + token = jsonResponse["token"].(string) + + req, _ = http.NewRequest("GET", "/accounts", nil) req.Header.Set("Authorization", "Bearer "+token) router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) // response is: gin.H{"accounts": accounts, "total": len(accounts)}, - var jsonResponse map[string]interface{} json.NewDecoder(w.Body).Decode(&jsonResponse) data := jsonResponse["data"].(map[string]interface{}) assert.Equal(t, data["accounts"].([]interface{})[0].(map[string]interface{})["username"], "admin") @@ -939,7 +950,6 @@ func setupRouter() *gin.Engine { // default password is "password" os.Setenv("ADMIN_PASSWORD", "admin") os.Setenv("SECRET_JWT", "secret") - os.Setenv("TOKENS_DIR", "./tokens") os.Setenv("CREDENTIALS_DIR", "./credentials") os.Setenv("PROMTAIL_ADDRESS", "127.0.0.1") os.Setenv("PROMTAIL_PORT", "6565") @@ -963,13 +973,6 @@ func setupRouter() *gin.Engine { os.Exit(1) } } - // create tokens directory - if _, err := os.Stat(os.Getenv("TOKENS_DIR")); os.IsNotExist(err) { - if err := os.MkdirAll(os.Getenv("TOKENS_DIR"), 0755); err != nil { - fmt.Printf("failed to create directory: %v\n", err) - os.Exit(1) - } - } router := setup() diff --git a/api/methods/auth.go b/api/methods/auth.go index d08fdfdb..568c8f7e 100644 --- a/api/methods/auth.go +++ b/api/methods/auth.go @@ -16,8 +16,7 @@ import ( "math/big" "net/http" "net/url" - "os" - "strings" + "slices" "time" "github.com/Jeffail/gabs/v2" @@ -37,50 +36,52 @@ import ( "github.com/pquerna/otp/totp" ) +var activeTokens map[string][]string + +func GetActiveTokens() map[string][]string { + if activeTokens == nil { + activeTokens = make(map[string][]string) + } + return activeTokens +} + func CheckTokenValidation(username string, token string) bool { - // read whole file - secrestListB, err := os.ReadFile(configuration.Config.TokensDir + "/" + username) - if err != nil { + tokens, ok := GetActiveTokens()[username] + if !ok { return false } - secrestList := string(secrestListB) - - // //check whether s contains substring text - return strings.Contains(secrestList, token) + return slices.Contains(tokens, token) } func SetTokenValidation(username string, token string) bool { - // open file - f, _ := os.OpenFile(configuration.Config.TokensDir+"/"+username, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) - defer f.Close() - - // write file with tokens - _, err := f.WriteString(token + "\n") - // check error - return err == nil + tokens, ok := GetActiveTokens()[username] + if !ok { + tokens = []string{} + } + // Avoid duplicates + if !slices.Contains(tokens, token) { + tokens = append(tokens, token) + activeTokens[username] = tokens + } + return true } func DelTokenValidation(username string, token string) bool { - // read whole file - secrestListB, errR := os.ReadFile(configuration.Config.TokensDir + "/" + username) - if errR != nil { + tokens, ok := GetActiveTokens()[username] + if !ok { return false } - secrestList := string(secrestListB) - - // match token to remove - res := strings.Replace(secrestList, token, "", 1) - - // open file - f, _ := os.OpenFile(configuration.Config.TokensDir+"/"+username, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - defer f.Close() - - // write file with tokens - _, err := f.WriteString(strings.TrimSpace(res) + "\n") - - // check error - return err == nil + // Remove the token from the user's tokens slice + newTokens := slices.DeleteFunc(tokens, func(s string) bool { + return s == token + }) + if len(newTokens) == 0 { + delete(activeTokens, username) + } else { + activeTokens[username] = newTokens + } + return true } func OTPVerify(c *gin.Context) { From 4033bd822baa72bb3efb97d9aa44ad2c6a2b8b17 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 3 Jul 2025 15:29:32 +0200 Subject: [PATCH 26/39] feat(vpn,api): remove ccd files Avoid misalignment between VPN configuration and database: now the database is the source of truth for the VPN config --- api/configuration/configuration.go | 2 +- api/entrypoint.sh | 3 ++- api/main_test.go | 2 -- api/methods/unit.go | 29 ++------------------ api/storage/storage.go | 22 +++++++-------- vpn/handle-connection | 43 +++++++++++++++++++----------- vpn/handle-disconnection | 9 +++++-- 7 files changed, 50 insertions(+), 60 deletions(-) diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 90793a2d..2af0e1e4 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -26,7 +26,7 @@ type Configuration struct { OpenVPNUDPPort string `json:"openvpn_udp_port"` OpenVPNStatusDir string `json:"openvpn_status_dir"` // Deprecated: it can be removed in the future - OpenVPNCCDDir string `json:"openvpn_ccd_dir"` + OpenVPNCCDDir string `json:"openvpn_ccd_dir"` // Deprecated: it can be removed in the future OpenVPNProxyDir string `json:"openvpn_proxy_dir"` OpenVPNPKIDir string `json:"openvpn_pki_dir"` OpenVPNMGMTSock string `json:"openvpn_mgmt_sock"` diff --git a/api/entrypoint.sh b/api/entrypoint.sh index 587f57f4..c6a845d3 100755 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -25,6 +25,7 @@ while [ ! -e "$socket" ]; do done # Create database config for OpenVPN hooks -echo REPORT_DB_URI=$REPORT_DB_URI > /etc/openvpn/db.env +echo REPORT_DB_URI=$REPORT_DB_URI > /etc/openvpn/conf.env +echo OVPN_NETMASK=$OVPN_NETMASK >> /etc/openvpn/conf.env exec "$@" diff --git a/api/main_test.go b/api/main_test.go index 653f353d..6b8e0e2b 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -437,8 +437,6 @@ func addUnit(t *testing.T) string { credsBytes, _ := json.Marshal(creds) werr := os.WriteFile(configuration.Config.CredentialsDir+"/"+unitID, credsBytes, 0644) assert.NoError(t, werr, "failed to write credentials file") - conf := "ifconfig-push 10.10.10.2 255.255.255.0 \n" - werr = os.WriteFile(configuration.Config.OpenVPNCCDDir+"/"+unitID, []byte(conf), 0644) assert.NoError(t, werr, "failed to write ccd file") if _, err := os.Stat(configuration.Config.OpenVPNPKIDir + "/issued/" + unitID + ".crt"); os.IsNotExist(err) { if _, err := os.Create(configuration.Config.OpenVPNPKIDir + "/issued/" + unitID + ".crt"); err != nil { diff --git a/api/methods/unit.go b/api/methods/unit.go index 597cf14a..a318d2bb 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -37,7 +37,6 @@ func GetUnits(c *gin.Context) { // extract user from JWT claims user := jwt.ExtractClaims(c)["id"].(string) - // list file in OpenVPNCCDDir units, err := storage.ListUnits() if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ @@ -237,7 +236,8 @@ func AddUnit(c *gin.Context) { } // check duplicates - if _, err := os.Stat(configuration.Config.OpenVPNCCDDir + "/" + jsonRequest.UnitId); err == nil { + _, err := storage.GetUnit(jsonRequest.UnitId) + if err == nil { c.JSON(http.StatusConflict, structs.Map(response.StatusConflict{ Code: 409, Message: "duplicated unit id", @@ -290,18 +290,6 @@ func AddUnit(c *gin.Context) { return } - // write conf - conf := "ifconfig-push " + freeIP + " " + configuration.Config.OpenVPNNetmask + "\n" - errWrite := os.WriteFile(configuration.Config.OpenVPNCCDDir+"/"+jsonRequest.UnitId, []byte(conf), 0644) - if errWrite != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, - Message: "cannot write conf file for: " + jsonRequest.UnitId, - Data: errWrite.Error(), - })) - return - } - // create record inside units table errCreate := storage.AddUnit(jsonRequest.UnitId, freeIP) if errCreate != nil { @@ -527,19 +515,6 @@ func DeleteUnit(c *gin.Context) { return } - // delete reservation/auth file - if _, err := os.Stat(configuration.Config.OpenVPNCCDDir + "/" + unitId); err == nil { - errDeleteAuth := os.Remove(configuration.Config.OpenVPNCCDDir + "/" + unitId) - if errDeleteAuth != nil { - c.JSON(http.StatusInternalServerError, structs.Map(response.StatusInternalServerError{ - Code: 500, - Message: "error in deletion auth file for: " + unitId, - Data: errDeleteAuth.Error(), - })) - return - } - } - // delete traefik conf if _, err := os.Stat(configuration.Config.OpenVPNProxyDir + "/" + unitId + ".yaml"); err == nil { errDeleteProxy := os.Remove(configuration.Config.OpenVPNProxyDir + "/" + unitId + ".yaml") diff --git a/api/storage/storage.go b/api/storage/storage.go index 9ac7c46d..11f1cc96 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -111,7 +111,7 @@ func MigrateUsersFromSqliteToPostgres(units []string) { // 2. Open SQLite DB sqliteDB, err := sql.Open("sqlite3", sqlitePath) if err != nil { - logs.Logs.Println("[ERR][MIGRATION] cannot open SQLite DB: " + err.Error()) + logs.Logs.Println("[INFO][MIGRATION] cannot open SQLite DB: skipping user migration") return } defer sqliteDB.Close() @@ -560,7 +560,8 @@ func MigrateUnitInfoFromFileToPostgres() []string { if !file.IsDir() { uuid := file.Name() infoFile := configuration.Config.OpenVPNStatusDir + "/" + uuid + ".info" - vpnFile := configuration.Config.OpenVPNCCDDir + "/" + uuid + ".vpn" + ccdFile := configuration.Config.OpenVPNCCDDir + "/" + uuid + // uuid.vpn file is not migrated because it does not persist: vpn status is restored as soon as the client re-connects ipaddr := loadUnitIP(uuid) // Check if the unit already exists in Postgres @@ -579,15 +580,6 @@ func MigrateUnitInfoFromFileToPostgres() []string { logs.Logs.Println("[WARNING][MIGRATION] error adding unit to Postgres:", uuid, addUnitErr.Error()) continue } - connected_since := 0 - statusFile, err := os.ReadFile(vpnFile) - if err == nil { - connected_since, _ = strconv.Atoi(string(statusFile)) - } - if connected_since > 0 { - // Update the unit with the connected_since timestamp - UpdateUnitVpnStatus(uuid, connected_since) - } if _, err := os.Stat(infoFile); err == nil { // read file, parse as JSON and then call AddUnit data, err := os.ReadFile(infoFile) @@ -603,12 +595,18 @@ func MigrateUnitInfoFromFileToPostgres() []string { if err := SetUnitInfo(uuid, info); err != nil { logs.Logs.Println("[WARNING][MIGRATION] error setting unit info for", uuid, ":", err.Error()) } - // remove the file + // remove the info file if err := os.Remove(infoFile); err != nil { logs.Logs.Println("[WARNING][MIGRATION] error removing file:", infoFile, err.Error()) } else { logs.Logs.Println("[INFO][MIGRATION] removed file:", infoFile) } + // remove ccd file + if err := os.Remove(ccdFile); err != nil { + logs.Logs.Println("[WARNING][MIGRATION] error removing file:", ccdFile, err.Error()) + } else { + logs.Logs.Println("[INFO][MIGRATION] removed file:", ccdFile) + } } ret = append(ret, uuid) } diff --git a/vpn/handle-connection b/vpn/handle-connection index ab0d5d8c..c3d7eea7 100755 --- a/vpn/handle-connection +++ b/vpn/handle-connection @@ -1,35 +1,48 @@ #!/bin/sh +source /etc/openvpn/conf.env + +# Send output to stdout to avoid flooding the logs +/usr/bin/psql $REPORT_DB_URI -c "UPDATE units SET vpn_connected_since = NOW() WHERE uuid = '$common_name';" > /dev/null + +# Dynamically assign VPN IP address +tmp_config=$1 +if [ -z "$tmp_config" ]; then + exit 0 +fi + +vpn_address=$(/usr/bin/psql "$REPORT_DB_URI" -t -A -c "SELECT vpn_address FROM units WHERE uuid = '$common_name';") +if [ -n "$vpn_address" ]; then + echo "ifconfig-push $vpn_address $OVPN_NETMASK" >> $tmp_config +else + vpn_address=$ifconfig_pool_remote_ip +fi + # Add route to traefik -username=$common_name -cat < /etc/openvpn/proxy/$username.yaml +cat < /etc/openvpn/proxy/$common_name.yaml http: # Add the router routers: - router$username: + router$common_name: entryPoints: - web middlewares: - - m$username-stripprefix - service: service-$username - rule: PathPrefix(\`/$username\`) + - m$common_name-stripprefix + service: service-$common_name + rule: PathPrefix(\`/$common_name\`) # Add the service services: - service-$username: + service-$common_name: loadBalancer: servers: - - url: https://$ifconfig_pool_remote_ip:9090/ + - url: https://$vpn_address:9090/ passHostHeader: true # Add middleware middlewares: - m$username-stripprefix: + m$common_name-stripprefix: stripPrefix: prefixes: - - "/$username" -EOF - -source /etc/openvpn/db.env - -/usr/bin/psql $REPORT_DB_URI -c "UPDATE units SET vpn_connected_since = NOW() WHERE uuid = '$common_name';" + - "/$common_name" +EOF \ No newline at end of file diff --git a/vpn/handle-disconnection b/vpn/handle-disconnection index a193b8ba..da85cfbb 100755 --- a/vpn/handle-disconnection +++ b/vpn/handle-disconnection @@ -1,4 +1,9 @@ #!/bin/sh -source /etc/openvpn/db.env -/usr/bin/psql $REPORT_DB_URI -c "UPDATE units SET vpn_connected_since = NULL WHERE uuid = '$common_name';" +source /etc/openvpn/conf.env +# Mark the unit as disconnected +/usr/bin/psql $REPORT_DB_URI -c "UPDATE units SET vpn_connected_since = NULL WHERE uuid = '$common_name';" >/dev/null +# Remove proxy pass configuration +rm -f /etc/openvpn/proxy/${common_name}.yaml + +exit 0 \ No newline at end of file From 4436f35830cba8407673cf6c5a82c82ad55a8ce2 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 3 Jul 2025 16:03:11 +0200 Subject: [PATCH 27/39] chore(vpn): bump Alpine and OpenVPN Resolve some known CVEs --- vpn/Containerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vpn/Containerfile b/vpn/Containerfile index a890712f..cba0c991 100644 --- a/vpn/Containerfile +++ b/vpn/Containerfile @@ -1,6 +1,6 @@ -FROM docker.io/alpine:3.16.9 AS dist +FROM docker.io/alpine:3.22.0 AS dist RUN apk add --no-cache \ - openvpn=2.5.6-r1 \ + openvpn \ easy-rsa \ postgresql-client COPY ip /sbin/ip From 22fcce5854f31a70217c6dede155c01ff18bcd06 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 4 Jul 2025 09:45:49 +0200 Subject: [PATCH 28/39] chore(build): cleanup container_ui --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index b936121a..58c682fa 100755 --- a/build.sh +++ b/build.sh @@ -7,7 +7,7 @@ images=() container=$(buildah from docker.io/debian:bookworm) ui_version="1.0.6" -trap "buildah rm ${container} ${container_api} ${container_proxy}" EXIT +trap "buildah rm ${container} ${container_api} ${container_proxy} ${container_ui}" EXIT echo "Installing build dependencies..." buildah run ${container} apt-get update From b0fa344e6bf1c5a95f7addd12558341e6b9fb406 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Fri, 4 Jul 2025 11:32:05 +0200 Subject: [PATCH 29/39] fix(api): update password on UpdateAccount The update password part was removed, but it's still used by the UI --- api/models/account.go | 1 + api/storage/storage.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/api/models/account.go b/api/models/account.go index ce35092f..bb1e7f66 100644 --- a/api/models/account.go +++ b/api/models/account.go @@ -27,6 +27,7 @@ type Account struct { } type AccountUpdate struct { + Password string `json:"password" structs:"password"` DisplayName string `json:"display_name" structs:"display_name"` Admin bool `json:"admin" structs:"admin"` UnitGroups []int `json:"unit_groups" structs:"unit_groups" binding:"required"` diff --git a/api/storage/storage.go b/api/storage/storage.go index 11f1cc96..0ac089bb 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -240,6 +240,21 @@ func AddAccount(account models.Account) (int, error) { func UpdateAccount(accountID string, account models.AccountUpdate) error { pgpool, pgctx := ReportInstance() var err error + if len(account.Password) > 0 { + // Update password only if it is provided + _, err = pgpool.Exec(pgctx, + `UPDATE accounts + SET password = $1 + WHERE id = $2 + `, + utils.HashPassword(account.Password), + accountID, + ) + if err != nil { + logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update accounts password query: " + err.Error()) + return err + } + } // Set unit_groups array unitGroupsStrs := make([]string, len(account.UnitGroups)) for i, v := range account.UnitGroups { From a69a92327dae9f728ab520c608e8325017573cb7 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Mon, 7 Jul 2025 09:04:08 +0200 Subject: [PATCH 30/39] fix(api): don't fail if SECRETS_DIR is not defined The SECRETES_DIR env var is not required anymore --- api/configuration/configuration.go | 3 +-- controller.te | 25 +++++++++++++++++++++++++ start.sh => dev.sh | 0 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 controller.te rename start.sh => dev.sh (100%) diff --git a/api/configuration/configuration.go b/api/configuration/configuration.go index 2af0e1e4..4dd1598f 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -140,8 +140,7 @@ func Init() { if os.Getenv("SECRETS_DIR") != "" { Config.SecretsDir = os.Getenv("SECRETS_DIR") } else { - logs.Logs.Println("[CRITICAL][ENV] SECRETS_DIR variable is empty") - os.Exit(1) + logs.Logs.Println("[INFO][ENV] SECRETS_DIR variable is empty") } if os.Getenv("OVPN_DIR") != "" { diff --git a/controller.te b/controller.te new file mode 100644 index 00000000..37e132c8 --- /dev/null +++ b/controller.te @@ -0,0 +1,25 @@ + +module controller 1.0; + +require { + type user_tmp_t; + type pasta_t; + type container_runtime_t; + type tun_tap_device_t; + type container_t; + type unconfined_t; + class dir read; + class chr_file { read write }; + class fifo_file setattr; + class tun_socket relabelfrom; +} + +#============= container_t ============== +allow container_t container_runtime_t:fifo_file setattr; + +#!!!! This avc is allowed in the current policy +allow container_t tun_tap_device_t:chr_file { read write }; +allow container_t unconfined_t:tun_socket relabelfrom; + +#============= pasta_t ============== +allow pasta_t user_tmp_t:dir read; diff --git a/start.sh b/dev.sh similarity index 100% rename from start.sh rename to dev.sh From 9e11a0c3ae684b7d17d22f86e38cc8d6789dea34 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Mon, 7 Jul 2025 09:10:58 +0200 Subject: [PATCH 31/39] feat(dev): setup env for UI development --- README.md | 53 +++++++++++++++++++++++----- dev.sh | 101 ++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 132 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ec2a96ab..551ab82c 100644 --- a/README.md +++ b/README.md @@ -7,31 +7,68 @@ Firewalls can register to the server using [ns-plug](https://github.com/NethServ - create a route inside the proxy to access the firewall Luci RPC - store credentials to access the remote firewall -## Quickstart +## Development environment -You can install it on [NS8](https://github.com/NethServer/ns8-nethsecurity-controller#install). +You can install the controller on [NS8](https://github.com/NethServer/ns8-nethsecurity-controller#install). -Otherwise, first make sure to have [podman](https://podman.io/) installed on your server. +If you need a development environment, you can use the `dev.sh` script to start a podman pod with all the containers needed to run the controller. +First make sure to have [podman](https://podman.io/) installed on your server. Containers should run under non-root users, but first you need to configure the tun device and the user. As root, execute: ``` -useradd -m controller -loginctl enable-linger controller - ip tuntap add dev tunsec mod tun ip addr add 172.21.0.1/16 dev tunsec ip link set dev tunsec up ``` +If you're running the dev environment on a distro with SELinux enabled, you also may need to create a module to allow the controller to access the tun device. +Just execute: +``` +checkmodule -M -m -o controller.mod controller.te +semodule_package -o controller.pp -m controller.mod +semodule -i controller.pp +``` + Then change to non-root user, clone this repository and execute: ``` su - controller -./start.sh +./dev.sh start ``` -The server will be available at `http://:8080/ui`. +To stop the pod, execute: +``` +./dev.sh stop +``` + +To run a specific image tag, you can use: +``` +IMAGE_TAG= ./dev.sh start +``` + +The server will be available at `http://localhost:8080/`. +Default credentials are: `admin/admin`. + +### UI development + +If you need to the develop the UI: + +- clone the [nethsecurity-ui](https://github.com/nethserver/nethsecurity-ui) +- start the controller using the `dev.sh` script +- start the UI in dev mode +- access to the dev UI URL generated by vite, usually `http://localhost:5173/` +``` +IMAGE_TAG=pr-123 ./dev.sh start +git clone git@github.com:NethServer/nethsecurity-ui.git +cd nethsecurity-ui +cat < .env.development +VITE_API_SCHEME=http +VITE_CONTROLLER_API_HOST=localhost:8080 +VITE_UI_MODE=controller +EOF +./dev.sh +``` ## How it works diff --git a/dev.sh b/dev.sh index 47e33f91..35f9841f 100755 --- a/dev.sh +++ b/dev.sh @@ -1,19 +1,92 @@ #!/bin/bash -vopts="" -if [[ ! -z "$1" && -d "$1" ]]; then - path=$(readlink -f $1) - vopts="-v $path:/var/www/localhost/htdocs" -fi +# This script manages a Podman pod for the NethSecurity project. +# It can start or stop a pod with multiple containers (VPN, API, UI, Proxy, and TimescaleDB). +# Optionally, it mounts a local directory as the UI's document root if provided: this option is useful for development purposes. -POD=${POD-nethsecurity-pod} +image_tag=${IMAGE_TAG:-latest} +POD="nethsecurity-pod" -podman pod stop $POD -podman pod rm $POD +start_pod() { + # Check if network device tunsec exists, if not fail + if ! ip link show dev tunsec > /dev/null 2>&1; then + echo "Network device tunsec does not exist, create it using root privileges:" + echo + echo " ip tuntap add dev tunsec mod tun" + echo " ip addr add 172.21.0.1/16 dev tunsec" + echo " ip link set dev tunsec up" + exit 1 + fi -podman pod create --replace --name $POD -podman run --rm --detach --network=host --cap-add=NET_ADMIN --device /dev/net/tun -v ovpn-data:/etc/openvpn/:z --pod $POD --name $POD-vpn ghcr.io/nethserver/nethsecurity-vpn:latest -podman run --rm --detach --network=host --volumes-from=$POD-vpn --pod $POD --name $POD-api -e FQDN=$(hostname -f) ghcr.io/nethserver/nethsecurity-api:latest -podman run --rm --detach --network=host --pod $POD --name $POD-ui $vopts ghcr.io/nethserver/nethsecurity-ui:latest -sleep 2 -podman run --rm --detach --network=host --volumes-from=$POD-vpn --pod $POD --name $POD-proxy ghcr.io/nethserver/nethsecurity-proxy:latest + # Stop the pod if it is already running + if podman pod exists $POD; then + echo "Pod $POD already exists" + exit 0 + fi + echo "Starting pod $POD with image tag $image_tag" + podman pod create --replace --name $POD + podman run --rm --detach --network=host --privileged --cap-add=NET_ADMIN --device /dev/net/tun -v ovpn-data:/etc/openvpn/:z --pod $POD --name $POD-vpn ghcr.io/nethserver/nethsecurity-vpn:$image_tag + podman run --rm --detach --network=host --name $POD-db --pod $POD -e POSTGRES_PASSWORD=password -e POSTGRES_USER=report docker.io/timescale/timescaledb:2.20.3-pg16 + # Wait for Postgres to be ready + echo -n "Waiting for Postgres to start..." + for i in {1..30}; do + if podman exec $POD-db pg_isready -U report > /dev/null 2>&1; then + break + fi + sleep 1 + done + # wait for db, pg_isready is not enough + sleep 5 + echo "OK" + cat > api.env < Date: Mon, 7 Jul 2025 10:15:25 +0200 Subject: [PATCH 32/39] fix(api): sync access to active tokens Make sure that concurrent access to active tokens is safe --- api/methods/auth.go | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/api/methods/auth.go b/api/methods/auth.go index 568c8f7e..742fe302 100644 --- a/api/methods/auth.go +++ b/api/methods/auth.go @@ -17,6 +17,7 @@ import ( "net/http" "net/url" "slices" + "sync" "time" "github.com/Jeffail/gabs/v2" @@ -36,50 +37,44 @@ import ( "github.com/pquerna/otp/totp" ) -var activeTokens map[string][]string - -func GetActiveTokens() map[string][]string { - if activeTokens == nil { - activeTokens = make(map[string][]string) - } - return activeTokens -} +var activeTokens sync.Map func CheckTokenValidation(username string, token string) bool { - tokens, ok := GetActiveTokens()[username] + value, ok := activeTokens.Load(username) if !ok { return false } + tokens := value.([]string) return slices.Contains(tokens, token) } func SetTokenValidation(username string, token string) bool { + value, _ := activeTokens.LoadOrStore(username, []string{}) + tokens := value.([]string) - tokens, ok := GetActiveTokens()[username] - if !ok { - tokens = []string{} - } // Avoid duplicates if !slices.Contains(tokens, token) { tokens = append(tokens, token) - activeTokens[username] = tokens + activeTokens.Store(username, tokens) } return true } func DelTokenValidation(username string, token string) bool { - tokens, ok := GetActiveTokens()[username] + value, ok := activeTokens.Load(username) if !ok { return false } + tokens := value.([]string) + // Remove the token from the user's tokens slice newTokens := slices.DeleteFunc(tokens, func(s string) bool { return s == token }) if len(newTokens) == 0 { - delete(activeTokens, username) + activeTokens.Delete(username) } else { - activeTokens[username] = newTokens + activeTokens.Store(username, newTokens) } return true } From 21125c88385652f48d3079b353485cd16264e4ea Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 8 Jul 2025 09:01:45 +0200 Subject: [PATCH 33/39] chore(api): add comments to db sql --- api/storage/report_schema.sql.tmpl | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index 6c9623ef..f6efc9ec 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -7,8 +7,12 @@ * author: Giacomo Sanchietti */ --- Create the schema for the report database +--------------------------------------------------------------------------------------------- +-- CORE CONFIGURATION TABLES +-- These tables are part of the core, used to store the core configuration of the application +--------------------------------------------------------------------------------------------- +-- This table contains all user accounts CREATE TABLE IF NOT EXISTS accounts ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, @@ -22,6 +26,7 @@ CREATE TABLE IF NOT EXISTS accounts ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- This table contains all unit groups CREATE TABLE IF NOT EXISTS unit_groups ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -31,6 +36,7 @@ CREATE TABLE IF NOT EXISTS unit_groups ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- This table contains all units CREATE TABLE IF NOT EXISTS units ( uuid UUID PRIMARY KEY, name TEXT, @@ -41,12 +47,19 @@ CREATE TABLE IF NOT EXISTS units ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- This table contains all unit credentials CREATE TABLE IF NOT EXISTS unit_credentials ( uuid UUID PRIMARY KEY, username TEXT, password TEXT ); +------------------------------------------------------------------ +-- REPORT TABLES +-- All the following tables are used for reporting inside Grafana +------------------------------------------------------------------ + +-- This table contains the list of OpenVPN instances configured inside the units CREATE TABLE IF NOT EXISTS openvpn_config ( uuid UUID NOT NULL references units(uuid), instance TEXT NOT NULL, @@ -56,6 +69,7 @@ CREATE TABLE IF NOT EXISTS openvpn_config ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- This table contains the list of WAN interfaces configured inside the units CREATE TABLE IF NOT EXISTS wan_config ( uuid UUID NOT NULL references units(uuid), interface TEXT NOT NULL, From fcc1d4cf4e7d4f499fbf423f2c2099259094d062 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 8 Jul 2025 09:08:33 +0200 Subject: [PATCH 34/39] chore(api): AddUnit, log errors Ease troubleshooting if easyresa fails --- api/methods/unit.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/api/methods/unit.go b/api/methods/unit.go index a318d2bb..82f1e305 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -22,6 +22,7 @@ import ( "github.com/NethServer/nethsecurity-api/response" "github.com/NethServer/nethsecurity-controller/api/configuration" + "github.com/NethServer/nethsecurity-controller/api/logs" "github.com/NethServer/nethsecurity-controller/api/models" "github.com/NethServer/nethsecurity-controller/api/socket" @@ -265,7 +266,19 @@ func AddUnit(c *gin.Context) { "EASYRSA_REQ_CN="+jsonRequest.UnitId, "EASYRSA_PKI="+configuration.Config.OpenVPNPKIDir, ) + + // Print the executed command for debug + cmdStr := configuration.Config.EasyRSAPath + " gen-req " + jsonRequest.UnitId + " nopass" + logs.Logs.Println("[DEBUG][AddUnit] Executing command: " + cmdStr) + + // Capture stdout and stderr + var stdout, stderr bytes.Buffer + cmdGenerateGenReq.Stdout = &stdout + cmdGenerateGenReq.Stderr = &stderr + + // Print stdout and stderr after execution if err := cmdGenerateGenReq.Run(); err != nil { + logs.Logs.Println("[ERROR][AddUnit] Command execution failed: "+err.Error(), " Stdout: "+stdout.String(), " Stderr: "+stderr.String()) c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, Message: "cannot generate request certificate for: " + jsonRequest.UnitId, From 0d54c8fffce370456135a6f1c9dbefe52858c026 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 8 Jul 2025 14:12:33 +0200 Subject: [PATCH 35/39] feat(api): units, return also groups This info can be used inside the UI to show if a unit belongs to a group --- api/README.md | 2 ++ api/storage/storage.go | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index cb72bc9c..85d1d2d3 100644 --- a/api/README.md +++ b/api/README.md @@ -210,6 +210,7 @@ The following rules apply: "ipaddress": "172.23.21.3", "id": "", "netmask": "255.255.255.0", + "groups": ["group1", "group2"], "vpn": { "bytes_rcvd": "21830", "bytes_sent": "5641", @@ -233,6 +234,7 @@ The following rules apply: "id": "", "netmask": "", "vpn": {}, + "groups": [], "info": { "unit_name": "", "version": "", diff --git a/api/storage/storage.go b/api/storage/storage.go index 0ac089bb..443e7ea4 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -895,7 +895,18 @@ func GetFreeIP() string { func ListUnits() ([]map[string]interface{}, error) { pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, "SELECT uuid, vpn_address, info::text, vpn_connected_since FROM units ORDER BY created_at ASC") + rows, err := pgpool.Query(pgctx, ` + SELECT + u.uuid, + u.vpn_address, + u.info::text, + u.vpn_connected_since, + COALESCE(array_agg(g.name) FILTER (WHERE g.id IS NOT NULL), '{}') AS groups + FROM units u + LEFT JOIN unit_groups g ON u.uuid = ANY(g.units) + GROUP BY u.uuid, u.vpn_address, u.info, u.vpn_connected_since + ORDER BY u.created_at ASC + `) if err != nil { logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in query execution:" + err.Error()) return nil, err @@ -909,10 +920,11 @@ func ListUnits() ([]map[string]interface{}, error) { var infoStr sql.NullString var connectedSince sql.NullTime var info map[string]interface{} + var groups []string vpn_info := make(map[string]interface{}) unit := make(map[string]interface{}) - if err := rows.Scan(&uuid, &ipaddress, &infoStr, &connectedSince); err != nil { + if err := rows.Scan(&uuid, &ipaddress, &infoStr, &connectedSince, &groups); err != nil { logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in row scan: " + err.Error()) continue } @@ -921,6 +933,7 @@ func ListUnits() ([]map[string]interface{}, error) { unit["ipaddress"] = ipaddress.String unit["netmask"] = configuration.Config.OpenVPNNetmask unit["vpn"] = vpn_info + unit["groups"] = groups if infoStr.Valid && infoStr.String != "" { if err := json.Unmarshal([]byte(infoStr.String), &info); err == nil { From 402c2d0e1b97f135ba7e676a5d321f6ab98f99e5 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 9 Jul 2025 10:21:08 +0200 Subject: [PATCH 36/39] fix(api): test, improve platform check --- api/main_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/main_test.go b/api/main_test.go index 6b8e0e2b..619e7fb2 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -553,7 +553,10 @@ func TestGetPlatformInfo(t *testing.T) { req.Header.Set("Authorization", "Bearer "+token) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - + platformInfoEnv := os.Getenv("PLATFORM_INFO") + var platformInfo map[string]interface{} + err := json.Unmarshal([]byte(platformInfoEnv), &platformInfo) + assert.NoError(t, err) // Step 3: Check response var resp map[string]interface{} _ = json.NewDecoder(w.Body).Decode(&resp) @@ -962,7 +965,7 @@ func setupRouter() *gin.Engine { os.Setenv("ISSUER_2FA", "test") os.Setenv("SECRETS_DIR", "./secrets") os.Setenv("ENCRYPTION_KEY", "12345678901234567890123456789012") - os.Setenv("PLATFORM_INFO", `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "nethserver_version":"1.6.0", "nethserver_system_id":"1234567890", "metrics_retention_days":30, "logs_retention_days":90}`) + os.Setenv("PLATFORM_INFO", `{"vpn_port":"1194","vpn_network":"192.168.100.0/24", "controller_version":"1.0.0", "metrics_retention_days":30, "logs_retention_days":90}`) // create directory configuration directory if _, err := os.Stat(os.Getenv("DATA_DIR")); os.IsNotExist(err) { From bd3ab862e288fd4fdf9b1f8e604164f72388de5b Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Wed, 9 Jul 2025 11:17:41 +0200 Subject: [PATCH 37/39] feat(api): unit_groups, add used_by field --- api/README.md | 144 +++++++++++++++++++++++++---------------- api/models/unit.go | 1 + api/storage/storage.go | 19 +++++- 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/api/README.md b/api/README.md index 85d1d2d3..ca31cd22 100644 --- a/api/README.md +++ b/api/README.md @@ -81,6 +81,7 @@ By default, a user can't access any unit. A user can be promoted to an admin user, which allows the user to manage other users and units. Admin users have the `admin` flag set to `true` and can: + - create, modify and delete user accounts - create, modify and delete units - create, modify and delete units groups @@ -489,7 +490,8 @@ The following rules apply: "description": "This is a test group", "units": ["unit_id_1", "unit_id_2"], "created": "2024-03-14T09:37:28+01:00", - "updated": "2024-03-14T10:00:00+01:00" + "updated": "2024-03-14T10:00:00+01:00", + "used_by": ["account_id_1", "account_id_2"] } ... ] @@ -955,8 +957,8 @@ The following rules apply: ### Ingest -This API is used to ingest metrics from connected units. It requires basic authentication and -takes `firewall_api` as a parameter. +This API is used to ingest metrics from connected units. It requires basic authentication and +takes `firewall_api` as a parameter. The `firewall_api` paramater is the name of the firewall API that is sending the metrics. The API accepts only POST requests abd requires the following headers: @@ -964,8 +966,9 @@ The API accepts only POST requests abd requires the following headers: - `Content-Type: application/json`: the content type must be JSON It responds with a 200 status code in case of success. Success example: + ```json -{"code":200,"data":null,"message":"success"} +{ "code": 200, "data": null, "message": "success" } ``` Possible error status codes are: @@ -998,20 +1001,22 @@ Error example: REQ ```json - { "data": [ - { - "timestamp": 1726819981, - "wan": "wan", - "interface": "eth1", - "event": "online" - }, - { - "timestamp": 1726820241, - "wan": "wan2", - "interface": "eth2", - "event": "offline" - }, - ]} + { + "data": [ + { + "timestamp": 1726819981, + "wan": "wan", + "interface": "eth1", + "event": "online" + }, + { + "timestamp": 1726820241, + "wan": "wan2", + "interface": "eth2", + "event": "offline" + } + ] + } ``` - `POST /ingest/dump-ts-attacks` @@ -1021,12 +1026,14 @@ Error example: REQ ```json - { "data": [ - { - "timestamp": 1726812650, - "ip": "200.91.234.36" - } - ]} + { + "data": [ + { + "timestamp": 1726812650, + "ip": "200.91.234.36" + } + ] + } ``` - `POST /ingest/dump-ts-malware` @@ -1036,15 +1043,17 @@ Error example: REQ ```json - { "data": [ - { - "timestamp": 1726811160, - "src": "5.6.32.54", - "dst": "1.2.3.4", - "category": "nethesislvl3v4", - "chain": "inp-wan" - } - ]} + { + "data": [ + { + "timestamp": 1726811160, + "src": "5.6.32.54", + "dst": "1.2.3.4", + "category": "nethesislvl3v4", + "chain": "inp-wan" + } + ] + } ``` - `POST /ingest/dump-ovpn-connections` @@ -1054,19 +1063,21 @@ Error example: REQ ```json - { "data": [ - { - "timestamp": 1726812276, - "instance": "ns_roadwarrior1", - "common_name": "user1", - "virtual_ip_addr": "10.9.10.41", - "remote_ip_addr": "1.2.3.4", - "start_time": 1726819476, - "duration": 4, - "bytes_received": 16343, - "bytes_sent": 7666 - } - ]} + { + "data": [ + { + "timestamp": 1726812276, + "instance": "ns_roadwarrior1", + "common_name": "user1", + "virtual_ip_addr": "10.9.10.41", + "remote_ip_addr": "1.2.3.4", + "start_time": 1726819476, + "duration": 4, + "bytes_received": 16343, + "bytes_sent": 7666 + } + ] + } ``` - `POST /ingest/dump-dpi-stats` @@ -1076,15 +1087,19 @@ Error example: REQ ```json - { "data": [ - { - "timestamp": 1726819203, - "client_address": "fe80::10ac:f709:5fb8:8fc3", - "client_name": "host1.test.local", - "protocol": "mdns", - "bytes": 123 - } - ]} + { + "data": [ + { + "timestamp": 1726819203, + "client_address": "fe80::10ac:f709:5fb8:8fc3", + "client_name": "host1.test.local", + "protocol": "mdns", + "bytes": 123 + } + ] + } + ``` + ``` ``` @@ -1094,8 +1109,18 @@ Error example: Store the openvpn configuration in the report database. REQ + ```json - {"data": [{"instance": "ns_roadwarrior1", "device": "tunrw1", "type": "rw", "name": "srv1"}]} + { + "data": [ + { + "instance": "ns_roadwarrior1", + "device": "tunrw1", + "type": "rw", + "name": "srv1" + } + ] + } ``` - `POST /ingest/dump-wan-config` @@ -1103,5 +1128,10 @@ Error example: REQ ```json - {"data": [{"interface": "wan1", "device": "eth0", "status": "online"}, {"interface": "wan2", "device": "eth5", "status": "offline"}]} + { + "data": [ + { "interface": "wan1", "device": "eth0", "status": "online" }, + { "interface": "wan2", "device": "eth5", "status": "offline" } + ] + } ``` diff --git a/api/models/unit.go b/api/models/unit.go index 7f5c9163..3bc70160 100644 --- a/api/models/unit.go +++ b/api/models/unit.go @@ -61,4 +61,5 @@ type UnitGroup struct { Units []string `json:"units" structs:"units"` CreatedAt time.Time `json:"created_at" structs:"created_at"` UpdatedAt time.Time `json:"updated_at" structs:"updated_at"` + UsedBy []string `json:"used_by" structs:"used_by"` } diff --git a/api/storage/storage.go b/api/storage/storage.go index 443e7ea4..456d73fb 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -697,7 +697,20 @@ func DeleteUnitGroup(groupID int) error { func ListUnitGroups() ([]models.UnitGroup, error) { pgpool, pgctx := ReportInstance() - rows, err := pgpool.Query(pgctx, `SELECT id, name, description, units, created_at, updated_at FROM unit_groups`) + rows, err := pgpool.Query(pgctx, ` + SELECT + ug.id, + ug.name, + ug.description, + ug.units, + ug.created_at, + ug.updated_at, + COALESCE(array_agg(a.username) FILTER (WHERE a.username IS NOT NULL), '{}') AS accounts + FROM unit_groups ug + LEFT JOIN accounts a ON ug.id = ANY(a.unit_groups) + GROUP BY ug.id, ug.name, ug.description, ug.units, ug.created_at, ug.updated_at + ORDER BY ug.id ASC + `) if err != nil { logs.Logs.Println("[ERR][STORAGE][GET_UNIT_GROUPS] error in query execution:" + err.Error()) return nil, err @@ -708,11 +721,13 @@ func ListUnitGroups() ([]models.UnitGroup, error) { for rows.Next() { var group models.UnitGroup var unitsArray []string - if err := rows.Scan(&group.ID, &group.Name, &group.Description, &unitsArray, &group.CreatedAt, &group.UpdatedAt); err != nil { + var accountsArray []string + if err := rows.Scan(&group.ID, &group.Name, &group.Description, &unitsArray, &group.CreatedAt, &group.UpdatedAt, &accountsArray); err != nil { logs.Logs.Println("[ERR][STORAGE][GET_UNIT_GROUPS] error in query row extraction" + err.Error()) continue } group.Units = unitsArray + group.UsedBy = accountsArray groups = append(groups, group) } return groups, nil From 5413056b6876952d2e0ee77e25c89bad974b0f67 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 10 Jul 2025 12:27:28 +0200 Subject: [PATCH 38/39] fix(api): migration, only set one admin user Make sure that only the user with id 1 is set as admin --- api/storage/storage.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/storage/storage.go b/api/storage/storage.go index 456d73fb..f50d1219 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -132,7 +132,11 @@ func MigrateUsersFromSqliteToPostgres(units []string) { continue } acc.Created, _ = time.Parse(time.RFC3339, createdStr) - acc.Admin = true + if acc.ID == 1 { + acc.Admin = true + } else { + acc.Admin = false // Default to false for other users + } users = append(users, acc) } if len(users) == 0 { From 567f33d6ab2d190212ab247a669a6a0fdd9994aa Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Thu, 10 Jul 2025 12:49:34 +0200 Subject: [PATCH 39/39] TEST: pull new UI --- build.sh | 2 +- ui/Containerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.sh b/build.sh index 58c682fa..bc9cf394 100755 --- a/build.sh +++ b/build.sh @@ -5,7 +5,7 @@ repobase="ghcr.io/nethserver" images=() container=$(buildah from docker.io/debian:bookworm) -ui_version="1.0.6" +ui_version="controller_refactor" trap "buildah rm ${container} ${container_api} ${container_proxy} ${container_ui}" EXIT diff --git a/ui/Containerfile b/ui/Containerfile index 45450799..96f639d2 100644 --- a/ui/Containerfile +++ b/ui/Containerfile @@ -5,7 +5,7 @@ RUN apk add --no-cache \ npm WORKDIR /build # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui -ARG UI_VERSION=1.28.3 +ARG UI_VERSION=controller_refactor # FIXME: when git 2.49 is available in alpine, use --revision="$UI_VERSION" instead of --branch="$UI_VERSION" RUN git clone --depth=1 --branch="$UI_VERSION" https://github.com/NethServer/nethsecurity-ui . \ && npm ci \ @@ -16,4 +16,4 @@ FROM docker.io/alpine:3.21.3 AS dist RUN apk add --no-cache lighttpd COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] -COPY --from=build /build/dist /var/www/localhost/htdocs \ No newline at end of file +COPY --from=build /build/dist /var/www/localhost/htdocs