diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94534ba7..661e6eed 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 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 + # 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 diff --git a/README.md b/README.md index b565ea38..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 @@ -74,6 +111,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 69aa2736..ca31cd22 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 @@ -29,7 +28,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` @@ -62,10 +62,63 @@ 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 + +- `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'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 +- 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'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 +- when a unit is deleted from the database, it is removed from all groups + ## APIs ### 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 @@ -158,6 +211,7 @@ CGO_ENABLED=0 go build "ipaddress": "172.23.21.3", "id": "", "netmask": "255.255.255.0", + "groups": ["group1", "group2"], "vpn": { "bytes_rcvd": "21830", "bytes_sent": "5641", @@ -181,6 +235,7 @@ CGO_ENABLED=0 go build "id": "", "netmask": "", "vpn": {}, + "groups": [], "info": { "unit_name": "", "version": "", @@ -276,7 +331,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` @@ -346,7 +401,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" } ``` @@ -365,7 +420,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" } @@ -406,6 +463,152 @@ 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", + "used_by": ["account_id_1", "account_id_2"] + } + ... + ] + }, + "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` @@ -431,6 +634,7 @@ CGO_ENABLED=0 go build "id": 2, "username": "test1", "password": "", + "admin": true, "display_name": "Test 1", "created": "2024-03-14T09:37:28+01:00" }, @@ -439,6 +643,7 @@ CGO_ENABLED=0 go build "id": 6, "username": "test2", "password": "", + "admin": false, "display_name": "Test 2", "created": "2024-03-14T11:43:33+01:00" } @@ -472,6 +677,7 @@ CGO_ENABLED=0 go build "id": 2, "username": "test3", "password": "", + "admin": false, "display_name": "Test 3", "created": "2024-03-14T09:37:28+01:00" } @@ -491,7 +697,9 @@ CGO_ENABLED=0 go build { "username": "test1", "password": "Nethesis,1234", - "display_name": "Test 1" + "display_name": "Test 1", + "unit_groups": [1, 2], + "admin": false } ``` @@ -503,7 +711,7 @@ CGO_ENABLED=0 go build { "code": 201, - "data": null, + "data": {"id": 5}, "message": "success" } ``` @@ -518,7 +726,9 @@ CGO_ENABLED=0 go build { "password": "Nethesis,4321", - "display_name": "Test 5" + "display_name": "Test 5", + "unit_groups": [1, 2], + "admin": false } ``` @@ -659,6 +869,62 @@ 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` + + 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` @@ -691,8 +957,8 @@ CGO_ENABLED=0 go build ### 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: @@ -700,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: @@ -734,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` @@ -757,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` @@ -772,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` @@ -790,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` @@ -812,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 + } + ] + } + ``` + ``` ``` @@ -830,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` @@ -839,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/configuration/configuration.go b/api/configuration/configuration.go index 4e067592..4dd1598f 100644 --- a/api/configuration/configuration.go +++ b/api/configuration/configuration.go @@ -10,10 +10,12 @@ package configuration import ( + "encoding/json" "os" "strings" "github.com/NethServer/nethsecurity-controller/api/logs" + "github.com/NethServer/nethsecurity-controller/api/models" "github.com/Showmax/go-fqdn" ) @@ -23,13 +25,13 @@ type Configuration struct { OpenVPNNetmask string `json:"openvpn_netmask"` OpenVPNUDPPort string `json:"openvpn_udp_port"` - OpenVPNStatusDir string `json:"openvpn_status_dir"` - OpenVPNCCDDir string `json:"openvpn_ccd_dir"` + OpenVPNStatusDir string `json:"openvpn_status_dir"` // Deprecated: it can be removed in the future + 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"` - ListenAddress string `json:"listen_address"` + ListenAddress []string `json:"listen_address"` AdminUsername string `json:"admin_username"` AdminPassword string `json:"admin_password"` @@ -37,11 +39,10 @@ type Configuration struct { SensitiveList []string `json:"sensitive_list"` 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"` + 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 +71,10 @@ type Configuration struct { GrafanaPostgresPassword string `json:"grafana_postgres_password"` RetentionDays string `json:"retention_days"` + + EncryptionKey string `json:"encryption_key"` + + PlatformInfo models.PlatformInfo `json:"platform_info"` } var Config = Configuration{} @@ -77,9 +82,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") != "" { @@ -112,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 { @@ -141,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") != "" { @@ -166,11 +164,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 { @@ -301,4 +295,26 @@ 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) + } + + 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/entrypoint.sh b/api/entrypoint.sh index 9d75317a..c6a845d3 100755 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -1,8 +1,6 @@ #!/bin/sh mkdir -p /etc/openvpn/sockets -mkdir -p /nethsecurity-api/tokens -mkdir -p /nethsecurity-api/credentials cd /nethsecurity-api @@ -26,4 +24,8 @@ while [ ! -e "$socket" ]; do fi done +# Create database config for OpenVPN hooks +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/go.mod b/api/go.mod index 1fc71aaf..9fb93862 100644 --- a/api/go.mod +++ b/api/go.mod @@ -9,26 +9,26 @@ 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 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 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/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/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 diff --git a/api/go.sum b/api/go.sum index 3f197177..05ab62fa 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,49 +12,25 @@ 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= 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= -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= @@ -65,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= @@ -120,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= @@ -161,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= @@ -202,9 +133,11 @@ 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 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= @@ -213,20 +146,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= @@ -237,10 +160,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= @@ -256,8 +175,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= @@ -273,17 +190,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= @@ -311,10 +220,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= @@ -322,6 +227,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= @@ -336,7 +243,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= @@ -346,11 +252,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= @@ -358,8 +259,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= @@ -377,10 +279,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= @@ -394,16 +292,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= @@ -415,18 +303,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= @@ -453,16 +329,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= @@ -470,16 +336,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= @@ -506,23 +362,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= @@ -540,16 +385,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= @@ -563,7 +398,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= @@ -574,14 +408,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= diff --git a/api/main.go b/api/main.go index 379c50a0..7e45f273 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() @@ -92,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) @@ -144,13 +148,35 @@ func setup() *gin.Engine { units.POST("", methods.AddUnit) 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) } // 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.Status(http.StatusOK) + }) + forwarded.GET("/:unit_id", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + // handle missing endpoint router.NoRoute(func(c *gin.Context) { c.JSON(http.StatusNotFound, structs.Map(response.StatusNotFound{ @@ -165,6 +191,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 3c31ca25..619e7fb2 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -2,25 +2,160 @@ package main import ( "bytes" + "crypto/rand" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" + "strings" "testing" + "time" + + 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" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" ) -func TestMainEndpoints(t *testing.T) { +var router *gin.Engine + +// 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 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() + 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) - router := setupRouter() + router = setupRouter() 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"}` @@ -31,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) { @@ -55,24 +191,92 @@ 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, 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) }) + t.Run("TestAddUpdateDeleteAccount", func(t *testing.T) { + w := httptest.NewRecorder() + // Add account + 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) + 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) + 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", "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) + 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) { @@ -82,7 +286,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") @@ -100,10 +305,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 @@ -115,7 +320,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) { @@ -124,15 +335,622 @@ 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) + + // 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"]) + }) +} + +func addUnit(t *testing.T) string { + // Generate a UUID v4 and convert it to string using the uuid package + unitID := uuid.New().String() + + 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") + 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) + } + } + // Manually add to the database: we can't call /units POST endpoint because + // it requires the presence of easyrsa binary and configuration files + newIp := storage.GetFreeIP() + storage.AddUnit(unitID, newIp) + + 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{ + 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"]) + 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) { + 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 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) + 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) + 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 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 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 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 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 { - os.Setenv("LISTEN_ADDRESS", "0.0.0.0:8000") + // 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" 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") @@ -145,7 +963,9 @@ 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") + 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) { @@ -154,13 +974,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/account.go b/api/methods/account.go index 1a68d94b..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, @@ -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}, })) } @@ -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, @@ -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, @@ -200,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 dd1f044f..742fe302 100644 --- a/api/methods/auth.go +++ b/api/methods/auth.go @@ -13,70 +13,70 @@ import ( "crypto/rand" "encoding/base32" "fmt" + "math/big" + "net/http" + "net/url" + "slices" + "sync" + "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" ) +var activeTokens sync.Map + func CheckTokenValidation(username string, token string) bool { - // read whole file - secrestListB, err := os.ReadFile(configuration.Config.TokensDir + "/" + username) - if err != nil { + value, ok := activeTokens.Load(username) + if !ok { return false } - secrestList := string(secrestListB) - - // //check whether s contains substring text - return strings.Contains(secrestList, token) + tokens := value.([]string) + 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") + value, _ := activeTokens.LoadOrStore(username, []string{}) + tokens := value.([]string) - // check error - return err == nil + // Avoid duplicates + if !slices.Contains(tokens, token) { + tokens = append(tokens, token) + activeTokens.Store(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 { + value, ok := activeTokens.Load(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 + 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 { + activeTokens.Delete(username) + } else { + activeTokens.Store(username, newTokens) + } + return true } func OTPVerify(c *gin.Context) { @@ -102,7 +102,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 +114,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 +166,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 +232,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 +241,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 +265,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 +342,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 +353,36 @@ 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 recoveryCodes +} - return true, string(secretB[:]) +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/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/methods/unit.go b/api/methods/unit.go index b4825170..82f1e305 100644 --- a/api/methods/unit.go +++ b/api/methods/unit.go @@ -16,24 +16,29 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "strconv" "strings" "time" "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" + "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" ) func GetUnits(c *gin.Context) { - // list file in OpenVPNCCDDir - units, err := ListUnits() + // extract user from JWT claims + user := jwt.ExtractClaims(c)["id"].(string) + + units, err := storage.ListUnits() if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, @@ -46,18 +51,12 @@ func GetUnits(c *gin.Context) { // loop through units var results []gin.H for _, unit := range units { - // read unit file - result, err := getUnitInfo(unit) - if err != nil { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 400, - Message: "Can't get unit info for: " + unit, - Data: err.Error(), - })) + unitId, ok := unit["id"].(string) + if !ok || !UserCanAccessUnit(user, unitId) { + continue } - // append to array - results = append(results, result) + results = append(results, unit) } // return 200 OK with data @@ -71,9 +70,18 @@ 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) + result, err := storage.GetUnit(unitId) if err != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ @@ -118,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) @@ -155,16 +175,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{ @@ -174,6 +194,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 { @@ -187,7 +217,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, @@ -207,7 +237,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", @@ -216,41 +247,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{ @@ -268,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, @@ -293,14 +303,13 @@ 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 { + // create record inside units table + errCreate := storage.AddUnit(jsonRequest.UnitId, freeIP) + if errCreate != nil { c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ Code: 400, - Message: "cannot write conf file for: " + jsonRequest.UnitId, - Data: errWrite.Error(), + Message: "cannot store unit record inside database for: " + jsonRequest.UnitId, + Data: errCreate.Error(), })) return } @@ -411,6 +420,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, @@ -420,34 +436,25 @@ 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 - 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, @@ -474,6 +481,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") @@ -487,8 +504,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(), })) @@ -503,33 +520,20 @@ 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(), })) 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.StatusBadRequest, structs.Map(response.StatusBadRequest{ - Code: 403, - 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") 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(), })) @@ -537,6 +541,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, @@ -545,60 +559,30 @@ 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 -} - 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) { // 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) @@ -713,98 +697,250 @@ 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 } -func getUnitInfo(unitId string) (gin.H, error) { - // read ccd dir for unit - unitFile, err := readUnitFile(unitId) - if err != nil { - return gin.H{}, err +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 } - // parse ccd dir file content - result := parseUnitFile(unitId, unitFile) - - // add info from unit - remote_info := getRemoteInfo(unitId) - if remote_info != nil { - result["info"] = remote_info - } else { - result["info"] = gin.H{} + 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 } - // 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{} + 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 } - return result, nil + c.JSON(http.StatusCreated, structs.Map(response.StatusCreated{ + Code: 201, + Message: "unit group added successfully", + Data: gin.H{"id": id}, + })) } -func getVPNInfo(unitId string) gin.H { - // read unit file - statusFile, err := os.ReadFile(configuration.Config.OpenVPNStatusDir + "/" + unitId + ".vpn") +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 { - return nil + c.JSON(http.StatusBadRequest, structs.Map(response.StatusBadRequest{ + Code: 400, + Message: "group_id must be an integer", + Data: err.Error(), + })) + return } - // convert timestamp to int - time, _ := strconv.Atoi(string(statusFile)) + 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 + } - // return vpn details - return gin.H{ - "connected_since": time, + 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 getRemoteInfo(unitId string) gin.H { - // read unit file - statusFile, err := os.ReadFile(configuration.Config.OpenVPNStatusDir + "/" + unitId + ".info") +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 { - return 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 } - // convert timestamp to int - var result map[string]interface{} - _ = json.Unmarshal(statusFile, &result) + 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 + } - // return vpn details - return result + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit group deleted successfully", + })) } -func readUnitFile(unitId string) ([]byte, error) { - // read unit file - unitFile, err := os.ReadFile(configuration.Config.OpenVPNCCDDir + "/" + unitId) +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 + } - // return results - return unitFile, err + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit groups listed successfully", + Data: groups, + })) } -func parseUnitFile(unitId string, unitFile []byte) gin.H { - // parse unit file - parts := strings.Split(string(unitFile), "\n") - parts = strings.Split(parts[0], " ") +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 + } - // compose result - result := gin.H{ - "id": unitId, - "ipaddress": parts[1], - "netmask": parts[2], + 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 } - return result + c.JSON(http.StatusOK, structs.Map(response.StatusOK{ + Code: 200, + Message: "unit group retrieved successfully", + Data: group, + })) } diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 7bbebc7b..a4599750 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -98,20 +98,20 @@ 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" } // 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, } } @@ -265,7 +265,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 == "" { @@ -303,3 +303,57 @@ func BasicAuth() gin.HandlerFunc { c.Next() } } + +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) + + if !valid { + 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 + } + + // 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", extra_log) + c.Header("X-Auth-User", username) + c.Next() + } +} diff --git a/api/models/account.go b/api/models/account.go index a0214bf5..bb1e7f66 100644 --- a/api/models/account.go +++ b/api/models/account.go @@ -14,17 +14,23 @@ 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"` - 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"` + Admin bool `json:"admin" structs:"admin"` + UnitGroups []int `json:"unit_groups" structs:"unit_groups" binding:"required"` } type PasswordChange struct { 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"` +} diff --git a/api/models/unit.go b/api/models/unit.go index 04e856c6..3bc70160 100644 --- a/api/models/unit.go +++ b/api/models/unit.go @@ -53,3 +53,13 @@ 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"` + UsedBy []string `json:"used_by" structs:"used_by"` +} diff --git a/api/storage/report_schema.sql.tmpl b/api/storage/report_schema.sql.tmpl index dbd1d64c..f6efc9ec 100644 --- a/api/storage/report_schema.sql.tmpl +++ b/api/storage/report_schema.sql.tmpl @@ -7,14 +7,59 @@ * 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, + 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 +); + +-- This table contains all unit groups +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 +); +-- This table contains all units CREATE TABLE IF NOT EXISTS units ( uuid UUID PRIMARY KEY, name TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + info JSONB, + vpn_address TEXT, + vpn_connected_since TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + 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, @@ -24,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, 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..f50d1219 100644 --- a/api/storage/storage.go +++ b/api/storage/storage.go @@ -14,8 +14,12 @@ import ( "context" "database/sql" _ "embed" + "encoding/json" + "fmt" "html/template" "os" + "strconv" + "strings" "time" "github.com/NethServer/nethsecurity-controller/api/configuration" @@ -27,256 +31,346 @@ 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 +//go:embed upgrade_schema.sql +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) -func Instance() *sql.DB { - if db == nil { - db = Init() - } - return db -} +// adminUsers is a list of user names that has the admin flag +var adminUsers = make([]string, 0) -func Init() *sql.DB { - // check if file exists - initSchema := false - if _, err := os.Stat(configuration.Config.DataDir + "/db.sqlite"); os.IsNotExist(err) { - initSchema = true - } +func Init() *pgxpool.Pool { + // Initialize PostgreSQL connection and schema + dbpool, dbctx = InitReportDb() - // try connection - db, err = sql.Open("sqlite3", configuration.Config.DataDir+"/db.sqlite") + // Migrate unit info from file to Postgres if needed + migratedUnits := MigrateUnitInfoFromFileToPostgres() + + // Migrate users from SQLite to Postgres if needed + MigrateUsersFromSqliteToPostgres(migratedUnits) + + // Migrate unit credentials from file to Postgres + MigrateUnitCredentialsFromFileToPostgres() + + ReloadACLs() + + // 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, + Admin: true, DisplayName: "Administrator", Created: time.Now(), } - - // add admin account - _ = AddAccount(admin) + _, _ = 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(units []string) { + // 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("[INFO][MIGRATION] cannot open SQLite DB: skipping user migration") + 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) + 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 { + 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 admin = true)`).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 a unit_group with all units + groupID := -1 + var groupErr error + if len(units) > 0 { + group := models.UnitGroup{ + Name: "Migrated", + Description: "All units migrated from old release", + 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: " + groupErr.Error()) + } + } + + // 6. Insert users into Postgres + for _, acc := range users { + // 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) + 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()) + } + 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") - // define query - _, err := db.Exec( - "INSERT INTO accounts (id, username, password, display_name, created) VALUES (null, ?, ?, ?, ?)", + // 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) (int, error) { + pgpool, pgctx := ReportInstance() + var id int + err := pgpool.QueryRow(pgctx, + "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.Created.Format(time.RFC3339), - ) - - // check error + 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 + ReloadACLs() + + return id, 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, + // 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 + } } - if len(account.DisplayName) > 0 { - // define and execute query - query := "UPDATE accounts set display_name = ? WHERE id = ?" - _, err = db.Exec( - query, - account.DisplayName, - accountID, - ) + // Set unit_groups array + unitGroupsStrs := make([]string, len(account.UnitGroups)) + for i, v := range account.UnitGroups { + unitGroupsStrs[i] = strconv.Itoa(v) } - - // check error + unitGroupsArray := "{" + strings.Join(unitGroupsStrs, ",") + "}" + _, err = pgpool.Exec(pgctx, + `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 insert accounts query: " + err.Error()) + logs.Logs.Println("[ERR][STORAGE][UPDATE_ACCOUNT] error in update accounts query: " + err.Error()) + return err } + ReloadACLs() + 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 - 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 + } } - - // check if user is admin or other user - return id == "1", id + return false } 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, 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()) } 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 { + 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()) } - - accountStatus, _ := utils.GetUserStatus(accountRow.Username) - accountRow.TwoFA = accountStatus == "1" - - // append results + accountRow.TwoFA = Is2FAEnabled(accountRow.Username) 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, 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()) } 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 { + 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()) } - - // 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()) } + ReloadACLs() + 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, ) - + 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 } @@ -309,6 +403,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 } @@ -324,7 +426,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 } @@ -334,17 +436,717 @@ 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 +} + +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 +} + +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, vpn_address, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (uuid) DO NOTHING + `, uuid, ipaddr) + 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 { + // No info found for this unit, return nil + 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 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 + // If the dir does not exists, just return + if _, err := os.Stat(configuration.Config.OpenVPNStatusDir); os.IsNotExist(err) { + return ret + } + 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() { + uuid := file.Name() + infoFile := configuration.Config.OpenVPNStatusDir + "/" + uuid + ".info" + 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 + 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 + addUnitErr := AddUnit(uuid, ipaddr) + if addUnitErr != nil { + logs.Logs.Println("[WARNING][MIGRATION] error adding unit to Postgres:", uuid, addUnitErr.Error()) + continue + } + 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 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) } } - return dbpool, dbctx + logs.Logs.Println("[INFO][MIGRATION] migrated", len(ret), "units from file to Postgres") + return ret +} + +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()) + } + + ReloadACLs() + + 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) + } + + ReloadACLs() + + 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) + } + + ReloadACLs() + + return nil +} + +func ListUnitGroups() ([]models.UnitGroup, error) { + pgpool, pgctx := ReportInstance() + 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 + } + defer rows.Close() + + var groups []models.UnitGroup + for rows.Next() { + var group models.UnitGroup + var unitsArray []string + 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 +} + +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 +} + +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") +} + +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() ([]map[string]interface{}, error) { + + pgpool, pgctx := ReportInstance() + 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 + } + defer rows.Close() + + units := make([]map[string]interface{}, 0) + for rows.Next() { + var uuid sql.NullString + var ipaddress sql.NullString + 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, &groups); err != nil { + logs.Logs.Println("[ERR][STORAGE][LIST_UNITS] error in row scan: " + err.Error()) + continue + } + + unit["id"] = uuid.String + 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 { + 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 units, 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{}, error) { + pgpool, pgctx := ReportInstance() + 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, err + } + + 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 + + if infoStr.Valid && infoStr.String != "" { + var info map[string]interface{} + 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{}{} + } + + if connectedSince.Valid { + vpn_info["connected_since"] = connectedSince.Time.Unix() + } + + 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) + } + + // 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) } diff --git a/api/storage/upgrade_schema.sql b/api/storage/upgrade_schema.sql new file mode 100644 index 00000000..78d1521f --- /dev/null +++ b/api/storage/upgrade_schema.sql @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: GPL-2.0-only + * + * author: Giacomo Sanchietti + */ + + +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 daf7f875..b225eb67 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" @@ -40,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() @@ -121,9 +108,108 @@ 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 { + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} - return statusS, err +// 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 +} + +// 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 +} + +// 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) +} + +// 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..bc9cf394 --- /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="controller_refactor" + +trap "buildah rm ${container} ${container_api} ${container_proxy} ${container_ui}" 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 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/dev.sh b/dev.sh new file mode 100755 index 00000000..35f9841f --- /dev/null +++ b/dev.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# 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. + +image_tag=${IMAGE_TAG:-latest} +POD="nethsecurity-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 + + # 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 < /config.yaml entryPoints: web: address: "$ip:$port" + forwardedHeaders: + trustedIPs: + - "127.0.0.1/32" accessLog: {} @@ -26,21 +85,23 @@ providers: serversTransport: insecureSkipVerify: true +core: + defaultRuleSyntax: v2 + 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 +109,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 +143,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 "$@" diff --git a/start.sh b/start.sh deleted file mode 100755 index 47e33f91..00000000 --- a/start.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -vopts="" -if [[ ! -z "$1" && -d "$1" ]]; then - path=$(readlink -f $1) - vopts="-v $path:/var/www/localhost/htdocs" -fi - -POD=${POD-nethsecurity-pod} - -podman pod stop $POD -podman pod rm $POD - -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 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 diff --git a/vpn/Containerfile b/vpn/Containerfile index 61963011..cba0c991 100644 --- a/vpn/Containerfile +++ b/vpn/Containerfile @@ -1,7 +1,8 @@ -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 \ - easy-rsa + openvpn \ + 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..c3d7eea7 100755 --- a/vpn/handle-connection +++ b/vpn/handle-connection @@ -1,34 +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 - -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 + - "/$common_name" +EOF \ No newline at end of file diff --git a/vpn/handle-disconnection b/vpn/handle-disconnection index 655443b0..da85cfbb 100755 --- a/vpn/handle-disconnection +++ b/vpn/handle-disconnection @@ -1,5 +1,9 @@ #!/bin/sh -# Remove openvpn instance -username=$common_name -rm -f /etc/openvpn/status/$username.vpn +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