From 5d2f90ce24f9b37decf56e287f718db3862a0b51 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 14:01:20 -0500 Subject: [PATCH] switched to fully self contained docker --- .dockerignore | 4 + .env.example | 25 +- Dockerfile | 9 +- README.md | 39 ++- cmd/migrate/main.go | 46 ++-- compose.tls.yaml | 4 + compose.yaml | 52 +++- config/.gitignore | 17 ++ config/backups/.gitkeep | 1 + config/broker/.gitkeep | 1 + config/certbot-webroot/.gitkeep | 1 + config/gitea/.gitkeep | 1 + config/gitea/config/.gitkeep | 1 + config/gitea/data/.gitkeep | 1 + config/letsencrypt/.gitkeep | 1 + deploy/gitea/compose.yaml | 40 +++ deploy/nginx/tapm.scity.us.conf | 23 -- .../nginx/templates/bootstrap.conf.template | 13 + deploy/nginx/templates/tls.conf.template | 76 ++++++ docker-entrypoint.sh | 5 + docs/deployment.md | 258 +++++++----------- go.mod | 15 +- go.sum | 53 +++- internal/app/auth.go | 4 +- internal/app/authorization.go | 55 ++-- internal/app/config.go | 6 +- internal/app/download.go | 11 +- internal/app/server.go | 10 +- manage.sh | 110 ++++++++ migrations/001_initial.sql | 200 ++++++++------ migrations/002_installer_actions.sql | 58 ---- migrations/003_audit_network.sql | 9 - migrations/004_remove_lan_ip.sql | 7 - update.sh | 204 -------------- 34 files changed, 688 insertions(+), 672 deletions(-) create mode 100644 compose.tls.yaml create mode 100644 config/.gitignore create mode 100644 config/backups/.gitkeep create mode 100644 config/broker/.gitkeep create mode 100644 config/certbot-webroot/.gitkeep create mode 100644 config/gitea/.gitkeep create mode 100644 config/gitea/config/.gitkeep create mode 100644 config/gitea/data/.gitkeep create mode 100644 config/letsencrypt/.gitkeep create mode 100644 deploy/gitea/compose.yaml delete mode 100644 deploy/nginx/tapm.scity.us.conf create mode 100644 deploy/nginx/templates/bootstrap.conf.template create mode 100644 deploy/nginx/templates/tls.conf.template create mode 100755 docker-entrypoint.sh create mode 100755 manage.sh delete mode 100644 migrations/002_installer_actions.sql delete mode 100644 migrations/003_audit_network.sql delete mode 100644 migrations/004_remove_lan_ip.sql delete mode 100755 update.sh diff --git a/.dockerignore b/.dockerignore index bcddbd7..f0d2bdb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,7 @@ README.md docs deploy +config +main +compose*.yaml +manage.sh diff --git a/.env.example b/.env.example index 0f1780d..3265bf2 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,21 @@ -TAPM_LISTEN_ADDR=0.0.0.0:8080 -TAPM_UPDATE_PEERS=10.10.1.122 -TAPM_UPDATE_SSH_USER=root -TAPM_PUBLIC_URL=https://tapm.scity.us -TAPM_DATABASE_DSN=tapm:change-me@tcp(127.0.0.1:3306)/tapm_broker?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci&multiStatements=true&time_zone=%27%2B00%3A00%27 -TAPM_GITEA_URL=https://git.scity.us -TAPM_GITEA_CLIENT_ID=replace-me -TAPM_GITEA_CLIENT_SECRET=replace-me +BROKER_DOMAIN=broker.example.com +GITEA_DOMAIN=git.example.com +LETSENCRYPT_EMAIL=admin@example.com +GITEA_SSH_PORT=2222 + +TAPM_LISTEN_ADDR=:8080 +TAPM_DATABASE_DSN=file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) +TAPM_PUBLIC_URL=https://broker.example.com +TAPM_GITEA_URL=https://git.example.com +TAPM_GITEA_CLIENT_ID=replace-after-creating-the-gitea-oauth-app +TAPM_GITEA_CLIENT_SECRET=replace-after-creating-the-gitea-oauth-app TAPM_GITEA_PACKAGE_OWNER=TAI TAPM_GITEA_PACKAGE_USERNAME=tapm-packages -TAPM_GITEA_PACKAGE_TOKEN=replace-me +TAPM_GITEA_PACKAGE_TOKEN=replace-with-read-package-token TAPM_GITEA_PACKAGE_WRITE_USERNAME=tapm-publisher -TAPM_GITEA_PACKAGE_WRITE_TOKEN=replace-me +TAPM_GITEA_PACKAGE_WRITE_TOKEN=replace-with-write-package-token TAPM_ALLOWED_GITEA_USERS=taiadmin -TAPM_COOKIE_SECRET=replace-with-at-least-32-random-bytes +TAPM_COOKIE_SECRET=replace-with-output-of-openssl-rand-base64-48 TAPM_DEFAULT_DURATION=3h TAPM_DEFAULT_HOST_LIMIT=3 TAPM_MAX_HOST_LIMIT=25 diff --git a/Dockerfile b/Dockerfile index acb56c0..9259076 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,12 +10,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tapm-se FROM alpine:3.22 RUN apk add --no-cache ca-certificates \ - && addgroup -S tapm \ - && adduser -S -G tapm -H tapm + && addgroup -S -g 1000 tapm \ + && adduser -S -u 1000 -G tapm -H tapm \ + && mkdir -p /data \ + && chown tapm:tapm /data WORKDIR /app COPY --from=build /out/tapm-server /usr/local/bin/tapm-server COPY --from=build /out/tapm-migrate /usr/local/bin/tapm-migrate COPY migrations /app/migrations +COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint USER tapm EXPOSE 8080 -ENTRYPOINT ["/usr/local/bin/tapm-server"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint"] diff --git a/README.md b/README.md index 5e3a442..e2b875b 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,30 @@ # TAPM Deployment Access TAPM Deployment Access is a short-lived authorization broker for protected -deployment packages and installer actions. Technicians authenticate with -Gitea, create a deployment code, and permit a limited number of hosts to use -explicitly selected capabilities during a fixed authorization window. +deployment packages and installer actions. Technicians authenticate through +the bundled Gitea service, create deployment codes, and authorize a limited +number of hosts for selected capabilities. -Default policy: +The production layout is a self-contained, single-VM Docker deployment: -- Public URL: `https://tapm.scity.us` -- Authorization lifetime: 3 hours -- Host limit: 3 -- Authentication: Gitea OAuth -- Shared state: MariaDB Galera -- Package storage: private Gitea Generic Package Registry +- Go broker with a persistent embedded SQLite database +- rootless Gitea with SQLite, repositories, and package registry +- Nginx reverse proxy for both public domains +- Certbot and Let's Encrypt HTTP-01 certificate management +- explicit runtime storage under `config/` beside the Compose files -See [docs/deployment.md](docs/deployment.md) for installation and configuration. -The ProxMenu integration contract is documented in -[docs/client-api.md](docs/client-api.md). +No external database or load balancer is required. The datacenter edge only +needs to forward TCP 80, 443, and 2222 to the Docker VM. -## Components - -- `cmd/server`: portal, OAuth flow, authorization API, uploads, and downloads -- `cmd/migrate`: ordered MariaDB schema migrations with an advisory lock -- `internal/app`: application, security, and registry proxy logic -- `deploy/nginx`: TLS reverse-proxy example -- `compose.yaml`: hardened, host-networked container deployment +See [docs/deployment.md](docs/deployment.md) for the installation, Gitea setup, +TLS bootstrap, repository move, renewal, backup, and update procedures. The +ProxMenu integration contract is in [docs/client-api.md](docs/client-api.md). ## Local checks ```sh -docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine \ - sh -c 'gofmt -w cmd internal && go test ./...' +GOCACHE=/tmp/tapm-go-cache go test ./... +docker compose --env-file .env.example config +docker compose --env-file .env.example -f deploy/gitea/compose.yaml config docker build -t tai/tapm-deployment-broker:local . ``` diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 4f87b7d..26d8cdc 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -12,44 +12,35 @@ import ( "strings" "time" - _ "github.com/go-sql-driver/mysql" + _ "modernc.org/sqlite" ) func main() { dsn := os.Getenv("TAPM_DATABASE_DSN") if dsn == "" { - log.Fatal("TAPM_DATABASE_DSN is required") + dsn = "file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)" } directory := os.Getenv("TAPM_MIGRATIONS_DIR") if directory == "" { directory = "/app/migrations" } - db, err := sql.Open("mysql", dsn) + db, err := sql.Open("sqlite", dsn) if err != nil { log.Fatal(err) } defer db.Close() + db.SetMaxOpenConns(1) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { log.Fatalf("database: %v", err) } - - var locked int - if err := db.QueryRowContext(ctx, `SELECT GET_LOCK('tapm_schema_migrations', 30)`).Scan(&locked); err != nil { - log.Fatal(err) - } - if locked != 1 { - log.Fatal("could not acquire migration lock") - } - defer db.ExecContext(context.Background(), `SELECT RELEASE_LOCK('tapm_schema_migrations')`) - if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( - version BIGINT UNSIGNED NOT NULL PRIMARY KEY, - applied_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) - ) ENGINE=InnoDB`); err != nil { + version INTEGER NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); err != nil { log.Fatal(err) } @@ -71,24 +62,35 @@ func main() { log.Fatalf("migration %q: %v", entry.Name(), err) } var applied int - if err := db.QueryRowContext( - ctx, - `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, - version, + if err := db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version, ).Scan(&applied); err != nil { log.Fatal(err) } if applied != 0 { continue } - body, err := os.ReadFile(filepath.Join(directory, entry.Name())) if err != nil { log.Fatal(err) } - if _, err := db.ExecContext(ctx, string(body)); err != nil { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + log.Fatal(err) + } + if _, err := tx.ExecContext(ctx, string(body)); err != nil { + _ = tx.Rollback() log.Fatalf("apply %s: %v", entry.Name(), err) } + if _, err := tx.ExecContext(ctx, + `INSERT INTO schema_migrations (version) VALUES (?)`, version, + ); err != nil { + _ = tx.Rollback() + log.Fatalf("record %s: %v", entry.Name(), err) + } + if err := tx.Commit(); err != nil { + log.Fatalf("commit %s: %v", entry.Name(), err) + } fmt.Printf("applied migration %s\n", entry.Name()) } } diff --git a/compose.tls.yaml b/compose.tls.yaml new file mode 100644 index 0000000..00a1c26 --- /dev/null +++ b/compose.tls.yaml @@ -0,0 +1,4 @@ +services: + nginx: + volumes: + - ./deploy/nginx/templates/tls.conf.template:/etc/nginx/templates/default.conf.template:ro diff --git a/compose.yaml b/compose.yaml index 3a6e520..fa91e73 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,20 +1,19 @@ -services: - migrate: - build: . - image: tai/tapm-deployment-broker:local - entrypoint: ["/usr/local/bin/tapm-migrate"] - env_file: .env - network_mode: host - restart: "no" +name: tapm +services: broker: build: . image: tai/tapm-deployment-broker:local - env_file: .env - network_mode: host + env_file: + - path: .env + required: false restart: unless-stopped init: true read_only: true + networks: + - edge + volumes: + - ./config/broker:/data security_opt: - no-new-privileges:true cap_drop: @@ -22,8 +21,37 @@ services: tmpfs: - /tmp:size=16m,mode=1777 healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health/ready"] + test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/health/ready"] interval: 30s timeout: 5s retries: 3 - start_period: 10s + start_period: 15s + + nginx: + image: nginx:1.28-alpine + restart: unless-stopped + networks: + - edge + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/nginx/templates/bootstrap.conf.template:/etc/nginx/templates/default.conf.template:ro + - ./config/letsencrypt:/etc/letsencrypt:ro + - ./config/certbot-webroot:/var/www/certbot:ro + environment: + BROKER_DOMAIN: ${BROKER_DOMAIN} + GITEA_DOMAIN: ${GITEA_DOMAIN} + security_opt: + - no-new-privileges:true + + certbot: + image: certbot/certbot:v5.7.0 + profiles: ["tools"] + volumes: + - ./config/letsencrypt:/etc/letsencrypt + - ./config/certbot-webroot:/var/www/certbot + +networks: + edge: + name: tapm-edge diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 0000000..f0159c4 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1,17 @@ +# Runtime data and secrets are local to the deployment VM. +* +!.gitignore +!broker/ +!broker/.gitkeep +!gitea/ +!gitea/.gitkeep +!gitea/data/ +!gitea/data/.gitkeep +!gitea/config/ +!gitea/config/.gitkeep +!letsencrypt/ +!letsencrypt/.gitkeep +!certbot-webroot/ +!certbot-webroot/.gitkeep +!backups/ +!backups/.gitkeep diff --git a/config/backups/.gitkeep b/config/backups/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/backups/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/broker/.gitkeep b/config/broker/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/broker/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/certbot-webroot/.gitkeep b/config/certbot-webroot/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/certbot-webroot/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/gitea/.gitkeep b/config/gitea/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/gitea/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/gitea/config/.gitkeep b/config/gitea/config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/gitea/config/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/gitea/data/.gitkeep b/config/gitea/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/gitea/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/letsencrypt/.gitkeep b/config/letsencrypt/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/letsencrypt/.gitkeep @@ -0,0 +1 @@ + diff --git a/deploy/gitea/compose.yaml b/deploy/gitea/compose.yaml new file mode 100644 index 0000000..422a8a3 --- /dev/null +++ b/deploy/gitea/compose.yaml @@ -0,0 +1,40 @@ +name: tapm-gitea + +services: + gitea: + image: docker.gitea.com/gitea:1.26.4-rootless + restart: unless-stopped + env_file: + - path: ../../.env + required: false + networks: + - edge + ports: + - "${GITEA_SSH_PORT:-2222}:2222" + volumes: + - ../../config/gitea/data:/var/lib/gitea + - ../../config/gitea/config:/etc/gitea + environment: + GITEA__database__DB_TYPE: sqlite3 + GITEA__database__PATH: /var/lib/gitea/data/gitea.db + GITEA__server__DOMAIN: ${GITEA_DOMAIN} + GITEA__server__ROOT_URL: https://${GITEA_DOMAIN}/ + GITEA__server__SSH_DOMAIN: ${GITEA_DOMAIN} + GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222} + GITEA__server__START_SSH_SERVER: "true" + GITEA__server__SSH_LISTEN_PORT: "2222" + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__security__INSTALL_LOCK: "true" + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3000/api/healthz"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + +networks: + edge: + external: true + name: tapm-edge diff --git a/deploy/nginx/tapm.scity.us.conf b/deploy/nginx/tapm.scity.us.conf deleted file mode 100644 index b931cd1..0000000 --- a/deploy/nginx/tapm.scity.us.conf +++ /dev/null @@ -1,23 +0,0 @@ -server { - listen 443 ssl http2; - server_name tapm.scity.us; - - ssl_certificate /etc/nginx/ssl/tapm.scity.us/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/tapm.scity.us/privkey.pem; - - client_max_body_size 1100m; - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - proxy_connect_timeout 30s; - proxy_send_timeout 30m; - proxy_read_timeout 30m; - proxy_request_buffering off; - proxy_buffering off; - } -} diff --git a/deploy/nginx/templates/bootstrap.conf.template b/deploy/nginx/templates/bootstrap.conf.template new file mode 100644 index 0000000..d9c88b6 --- /dev/null +++ b/deploy/nginx/templates/bootstrap.conf.template @@ -0,0 +1,13 @@ +server { + listen 80; + server_name ${BROKER_DOMAIN} ${GITEA_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 503 "TLS setup is in progress.\n"; + add_header Content-Type text/plain; + } +} diff --git a/deploy/nginx/templates/tls.conf.template b/deploy/nginx/templates/tls.conf.template new file mode 100644 index 0000000..0e29543 --- /dev/null +++ b/deploy/nginx/templates/tls.conf.template @@ -0,0 +1,76 @@ +resolver 127.0.0.11 valid=30s; + +upstream broker_backend { + zone broker_backend 64k; + server broker:8080 resolve; +} + +upstream gitea_backend { + zone gitea_backend 64k; + server gitea:3000 resolve; +} + +server { + listen 80; + server_name ${BROKER_DOMAIN} ${GITEA_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name ${BROKER_DOMAIN}; + + ssl_certificate /etc/letsencrypt/live/${BROKER_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${BROKER_DOMAIN}/privkey.pem; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 1100m; + + location / { + proxy_pass http://broker_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 30s; + proxy_send_timeout 30m; + proxy_read_timeout 30m; + proxy_request_buffering off; + proxy_buffering off; + } +} + +server { + listen 443 ssl; + http2 on; + server_name ${GITEA_DOMAIN}; + + ssl_certificate /etc/letsencrypt/live/${BROKER_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${BROKER_DOMAIN}/privkey.pem; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 1100m; + + location / { + proxy_pass http://gitea_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 300s; + } +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..3e3803f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +/usr/local/bin/tapm-migrate +exec /usr/local/bin/tapm-server diff --git a/docs/deployment.md b/docs/deployment.md index d3c93a6..f5d8a83 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,221 +1,147 @@ -# Deployment +# Single-VM deployment -This service runs active-active on both webserver VMs. Both installations use -the same MariaDB Galera database, Gitea application, package registry, and -secrets. The containers use Linux host networking so `127.0.0.1:3306` reaches -each webserver's local HAProxy listener. +The production deployment lives at: -## 1. DNS and TLS - -Create `tapm.scity.us` in PowerDNS and point it at the existing HAProxy -frontend. Install the certificate using the existing certificate automation. -Set the broker to listen on all addresses on both webservers: - -```dotenv -TAPM_LISTEN_ADDR=0.0.0.0:8080 -TAPM_DISPLAY_TIME_ZONE=America/Chicago +```text +/opt/idssys/TA-Deployment-Access ``` -The wildcard bind accepts connections through each node's physical address and -through the Keepalived address `10.10.1.120` on whichever node currently owns -it. The container health check independently uses `127.0.0.1:8080`. Restrict -port 8080 to the load-balancer and trusted management addresses. If Nginx -terminates TLS locally and neither direct nor Keepalived access is required, -the broker can instead bind to `127.0.0.1:8080`. +It consists of four containers: -An example virtual host is in -[`deploy/nginx/tapm.scity.us.conf`](../deploy/nginx/tapm.scity.us.conf). -Adjust only the certificate paths if the local convention differs. +- TAPM broker, with an embedded SQLite database +- Gitea, with its own embedded SQLite database and repository storage +- Nginx, terminating TLS for the broker and Gitea +- Certbot, run on demand for certificate issuance and renewal -## 2. MariaDB Galera +Only Nginx ports 80 and 443 and Gitea SSH port 2222 are published. The broker +and Gitea HTTP services are reachable only on the private `tapm-edge` Docker +network. -Create a dedicated database and application users limited to the two webserver -addresses. Run this once against the cluster as a database administrator, -replacing the password with the same locally generated value in both accounts: +## 1. VM and network -```sql -CREATE DATABASE tapm_broker - CHARACTER SET utf8mb4 - COLLATE utf8mb4_unicode_ci; -CREATE USER 'tapm'@'10.10.1.121' IDENTIFIED BY 'replace-with-a-random-password'; -CREATE USER 'tapm'@'10.10.1.122' IDENTIFIED BY 'replace-with-a-random-password'; -GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES - ON tapm_broker.* TO 'tapm'@'10.10.1.121'; -GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES - ON tapm_broker.* TO 'tapm'@'10.10.1.122'; -FLUSH PRIVILEGES; -``` +Install Docker Engine with Compose v2. Permit inbound TCP 80, 443, and 2222. +Forward those ports from the datacenter edge to this VM. Create public DNS A/AAAA +records for both application names before requesting the certificate. -The Keepalived address `10.10.1.120` is not included because outbound HAProxy -connections normally originate from the physical webserver address. Add a -third account for `10.10.1.120` only if a connection test or the HAProxy -configuration proves that it explicitly uses the virtual address as its source. +Port 80 must remain reachable for HTTP-01 certificate renewal. If IPv6 is +published, it must reach this same VM. -## 3. Gitea accounts and OAuth - -Create these separately: - -1. The `taiadmin` technician account used to sign in to the portal. -2. A `tapm-packages` service account with read access only to the private - `TAI/files` package owner. Create a personal access token for this account - and store it only in the broker environment file. -3. A `tapm-publisher` service account with `write:package` access. Its token is - used only by authenticated portal uploads. -4. An OAuth2 application with callback URL - `https://tapm.scity.us/auth/callback`. - -The OAuth client secret authenticates the portal to Gitea. The read token is -used only while proxying an authorized file, and the write token is used only -for authenticated uploads. None are returned to a technician or Proxmox host. -The broker requests only Gitea's `read:user` OAuth scope, which it uses to -confirm the signed-in username against `TAPM_ALLOWED_GITEA_USERS`. - -## 4. Broker configuration - -On each webserver: +## 2. Install the repository ```sh -git clone https://git.scity.us/TAI/TA-Deployment-Broker.git -cd TA-Deployment-Broker +sudo mkdir -p /opt/idssys +sudo git clone GITEA-REPOSITORY-URL /opt/idssys/TA-Deployment-Access +sudo chown -R 1000:1000 /opt/idssys/TA-Deployment-Access +cd /opt/idssys/TA-Deployment-Access cp .env.example .env chmod 600 .env ``` -Edit `.env` and use the same secrets on both webservers. The listen address is -the one node-specific value. Generate the cookie secret with: +Set `BROKER_DOMAIN`, `GITEA_DOMAIN`, and `LETSENCRYPT_EMAIL` first. Replace all +example domains in the TAPM variables. Generate the cookie secret with: ```sh openssl rand -base64 48 ``` -The database DSN must retain `parseTime=true`, `multiStatements=true`, and the -encoded UTC `time_zone` setting from `.env.example`. Its address remains -`127.0.0.1:3306`, which uses the local HAProxy frontend rather than selecting a -single Galera member. +Runtime state is deliberately visible below the checkout: -## 5. Build and migrate +```text +config/ +├── broker/ # tapm.db and SQLite WAL files +├── gitea/ +│ ├── config/ # app.ini and Gitea configuration +│ └── data/ # repositories, packages, and gitea.db +├── letsencrypt/ # account data, certificate, and private key +├── certbot-webroot/ # HTTP-01 challenge files +└── backups/ # local offline snapshots +``` -Build the image on each webserver: +The contents are ignored by Git. They must never be committed. + +## 3. Bootstrap Gitea and TLS ```sh -docker compose build +./manage.sh bootstrap ``` -Apply migrations from only one webserver: +This prepares the runtime directories, creates the private Docker network, +starts Gitea and the HTTP-only Nginx configuration, obtains one certificate +covering both domains, and switches Nginx to TLS. + +Create the initial Gitea administrator: ```sh -docker compose run --rm migrate +docker compose --env-file .env -f deploy/gitea/compose.yaml exec gitea \ + gitea admin user create \ + --admin --username taiadmin --email ADMIN-EMAIL \ + --password 'TEMPORARY-RANDOM-PASSWORD' --must-change-password ``` -Migrations use a MariaDB advisory lock, so an accidental simultaneous run -cannot apply the same migration twice. +Sign in to Gitea at `https://GITEA_DOMAIN`, create the `TAI` organization, and +create: -## 6. Start both instances +1. `tapm-packages`, with a read-only package token. +2. `tapm-publisher`, with a write package token. +3. An OAuth2 application whose callback is + `https://BROKER_DOMAIN/auth/callback`. -On both webservers: +Place those token and OAuth values in `.env`. The broker cannot start until all +required credentials are present. + +## 4. Start the complete deployment ```sh -docker compose up -d broker -curl --fail "http://127.0.0.1:8080/health/ready" +./manage.sh start +./manage.sh status +curl --fail "https://BROKER_DOMAIN/health/ready" +curl --fail "https://GITEA_DOMAIN/api/healthz" ``` -There is no local application state and no session affinity requirement. +The broker automatically applies SQLite schema migrations before starting. -For later updates, run the repository update helper from either webserver: +## 5. Move repositories + +For each existing repository, create an empty matching repository in the new +Gitea and mirror all refs: ```sh -/opt/idssys/ta-deployment-broker/update.sh +git clone --mirror OLD-REPOSITORY-URL +cd REPOSITORY.git +git push --mirror ssh://git@GITEA_DOMAIN:2222/TAI/REPOSITORY.git ``` -The helper updates and health-checks the local broker first, then connects as -`TAPM_UPDATE_SSH_USER` to each comma-separated host in `TAPM_UPDATE_PEERS` and -performs the same local-only check there. This provides a sequential rolling -update from any node. An unreachable peer is skipped with a warning; a reachable -peer whose update fails causes the command to fail. The SSH user defaults to -`root` when omitted. +Update developer remotes, CI credentials, submodules, documentation, and the Go +module path only if the hostname embedded in the module path is also changing. +Move the deployment repository last so this checkout remains updateable during +the transition. -Configure each webserver's `.env` with its other broker: +## 6. Renewal and backups -```dotenv -# Webserver-Node1 -TAPM_UPDATE_PEERS=10.10.1.122 -TAPM_UPDATE_SSH_USER=root +Run renewal twice daily from root's crontab: -# Webserver-Node2 -TAPM_UPDATE_PEERS=10.10.1.121 -TAPM_UPDATE_SSH_USER=root +```cron +17 3,15 * * * cd /opt/idssys/TA-Deployment-Access && ./manage.sh renew ``` -Additional peers can be listed with commas. The updater reads only these named -settings and does not source `.env` as executable shell code. - -Each node fetches and compares its current tracked branch with its upstream. If -both commits match, that node's Docker services are untouched. When an update -exists, the helper fast-forwards the branch, rebuilds the image, applies pending -migrations, recreates the broker container, and waits up to 150 seconds for its -Docker health check. It refuses tracked local changes and detached, untracked, -locally-ahead, or diverged branches. - -## 7. HAProxy health check - -The load balancer should consider an origin healthy only when -`GET /health/ready` returns HTTP 200. `/health/live` checks only the process; -`/health/ready` also verifies Galera connectivity. - -Both healthy webservers can receive traffic. `leastconn` is useful because -package uploads and downloads stay open longer than portal requests. - -## 8. Publish a protected package - -The normal workflow is **Protected packages → Update a package** in the portal. -The package ID is the stable name used by the broker, ProxMenu, and Gitea; a -separate registry package name is not required. The broker streams the -replacement directly to Gitea, calculates SHA-256, updates the catalog, removes -the prior Gitea version, and writes audit events without buffering the installer -on local disk. - -An authorization grants access to the stable package ID, not a particular -version. Existing active codes and download sessions resolve the package's -current registry version and checksum, so they automatically use a replacement -after it is registered. - -The load balancer or Nginx must allow request bodies up to -`TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The -default is 1 GiB and 30 minutes. - -For recovery, a package can still be published directly: +Create an application-consistent local snapshot with: ```sh -curl --user 'publisher:PACKAGE_WRITE_TOKEN' \ - --upload-file ./SentinelAgent_linux_x86_64_v26_1_1_31.deb \ - 'https://git.scity.us/api/packages/TAI/generic/sentinelone-linux/26.1.1.31/SentinelAgent_linux_x86_64_v26_1_1_31.deb' +./manage.sh backup ``` -Calculate its checksum: +The backup briefly stops both SQLite writers. Copy `config/backups/` to storage +outside the VM. A backup left only on this VM does not protect against VM or +datacenter loss. Also back up `.env` through a secrets-aware system. + +## 7. Updates ```sh -sha256sum SentinelAgent_linux_x86_64_v26_1_1_31.deb +cd /opt/idssys/TA-Deployment-Access +git pull --ff-only +docker compose --env-file .env -f deploy/gitea/compose.yaml pull +./manage.sh start ``` -Sign in at `https://tapm.scity.us`, expand **Register package metadata -manually**, enter the matching values, and enable it. The broker never uses the -technician's Gitea credentials for package operations. - -## 9. Backup and rotation - -- Include `tapm_broker` in the existing Galera backup policy. -- Audit records are retained indefinitely unless an administrator establishes a - database retention policy. The Codes view previews the newest 15 records; the - Audit view can filter retained history by event, customer/deployment label, - user, host, package, LAN/WAN IP, or details and displays up to the newest 250 - matching events. Audit timestamps are stored in UTC and rendered in - `TAPM_DISPLAY_TIME_ZONE`. Deployment exchanges record both the device LAN - address reported by TAPM and the WAN/source address received through the - trusted proxy chain. -- Keep `.env` outside Git and readable only by the service administrator. -- Rotate both Gitea package tokens and the OAuth secret if either webserver is - compromised. -- Changing `TAPM_COOKIE_SECRET` signs out portal users; it does not invalidate - active deployment codes. -- Revoke active deployment authorizations from the portal when a deployment - ends early. +Pin image versions as supplied and review release notes before changing them. +Never change Gitea between rootless and rootful image families in place. diff --git a/go.mod b/go.mod index 53a1600..238b1ed 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,17 @@ module git.scity.us/TAI/TA-Deployment-Broker go 1.24 -require github.com/go-sql-driver/mysql v1.9.3 +require modernc.org/sqlite v1.38.2 -require filippo.io/edwards25519 v1.1.0 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index 4bcdcfa..aac187a 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,49 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/auth.go b/internal/app/auth.go index 8dba1d7..0fcf0da 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -216,7 +216,7 @@ func (s *Server) currentTechnician(r *http.Request) (*technician, error) { r.Context(), `SELECT gitea_login, display_name, csrf_token FROM technician_sessions - WHERE token_hash = ? AND expires_at > UTC_TIMESTAMP(6)`, + WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, tokenHash[:], ).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken) if err != nil { @@ -224,7 +224,7 @@ func (s *Server) currentTechnician(r *http.Request) (*technician, error) { } _, _ = s.db.ExecContext( r.Context(), - `UPDATE technician_sessions SET last_seen_at = UTC_TIMESTAMP(6) + `UPDATE technician_sessions SET last_seen_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash[:], ) diff --git a/internal/app/authorization.go b/internal/app/authorization.go index 78a5516..2627ed9 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -215,10 +215,10 @@ func auditQuery(filters auditFilters, limit int) (string, []any) { var arguments []any timeClauses := map[string]string{ - "24h": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY", - "7d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY", - "30d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY", - "90d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY", + "24h": " AND ae.created_at >= datetime('now', '-1 day')", + "7d": " AND ae.created_at >= datetime('now', '-7 days')", + "30d": " AND ae.created_at >= datetime('now', '-30 days')", + "90d": " AND ae.created_at >= datetime('now', '-90 days')", } query += timeClauses[filters.TimeRange] @@ -240,11 +240,11 @@ func auditQuery(filters auditFilters, limit int) (string, []any) { if filter.value == "" { continue } - query += " AND LOCATE(?, " + filter.column + ") > 0" + query += " AND instr(" + filter.column + ", ?) > 0" arguments = append(arguments, filter.value) } if filters.SourceIP != "" { - query += " AND LOCATE(?, ae.source_ip) > 0" + query += " AND instr(ae.source_ip, ?) > 0" arguments = append(arguments, filters.SourceIP) } query += " ORDER BY ae.created_at DESC LIMIT ?" @@ -325,21 +325,23 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err WHERE h.authorization_id = a.id), a.created_at, a.expires_at, a.revoked_at, COALESCE(( - SELECT GROUP_CONCAT(p.display_name - ORDER BY p.display_name SEPARATOR ', ') - FROM authorization_packages ap - JOIN packages p ON p.id = ap.package_id - WHERE ap.authorization_id = a.id + SELECT GROUP_CONCAT(display_name, ', ') + FROM (SELECT p.display_name + FROM authorization_packages ap + JOIN packages p ON p.id = ap.package_id + WHERE ap.authorization_id = a.id + ORDER BY p.display_name) ), ''), COALESCE(( - SELECT GROUP_CONCAT(ia.display_name - ORDER BY ia.sort_order, ia.display_name SEPARATOR ', ') - FROM authorization_actions aa - JOIN installer_actions ia ON ia.slug = aa.action_slug - WHERE aa.authorization_id = a.id + SELECT GROUP_CONCAT(display_name, ', ') + FROM (SELECT ia.display_name + FROM authorization_actions aa + JOIN installer_actions ia ON ia.slug = aa.action_slug + WHERE aa.authorization_id = a.id + ORDER BY ia.sort_order, ia.display_name) ), '') FROM authorizations a - WHERE a.created_at > UTC_TIMESTAMP(6) - INTERVAL 30 DAY + WHERE a.created_at > datetime('now', '-30 days') ORDER BY a.created_at DESC LIMIT 100`, ) @@ -489,7 +491,7 @@ func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Reques result, err := s.db.ExecContext( r.Context(), `UPDATE authorizations - SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(6)) + SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP) WHERE id = ?`, id, ) @@ -504,7 +506,7 @@ func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Reques } _, _ = s.db.ExecContext( r.Context(), - `UPDATE download_sessions SET revoked_at = UTC_TIMESTAMP(6) + `UPDATE download_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE authorization_id = ? AND revoked_at IS NULL`, id, ) @@ -568,13 +570,14 @@ func (s *Server) savePackage( `INSERT INTO packages (slug, display_name, package_name, package_version, file_name, sha256, enabled) VALUES (?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - display_name = VALUES(display_name), - package_name = VALUES(package_name), - package_version = VALUES(package_version), - file_name = VALUES(file_name), - sha256 = VALUES(sha256), - enabled = VALUES(enabled)`, + ON CONFLICT(slug) DO UPDATE SET + display_name = excluded.display_name, + package_name = excluded.package_name, + package_version = excluded.package_version, + file_name = excluded.file_name, + sha256 = excluded.sha256, + enabled = excluded.enabled, + updated_at = CURRENT_TIMESTAMP`, slug, displayName, packageName, diff --git a/internal/app/config.go b/internal/app/config.go index a9cda3c..60a6d6c 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -37,7 +37,10 @@ func LoadConfig() (Config, error) { var err error cfg.ListenAddr = envDefault("TAPM_LISTEN_ADDR", ":8080") - cfg.DatabaseDSN = os.Getenv("TAPM_DATABASE_DSN") + cfg.DatabaseDSN = envDefault( + "TAPM_DATABASE_DSN", + "file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", + ) cfg.GiteaClientID = os.Getenv("TAPM_GITEA_CLIENT_ID") cfg.GiteaClientSecret = os.Getenv("TAPM_GITEA_CLIENT_SECRET") cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI") @@ -103,7 +106,6 @@ func LoadConfig() (Config, error) { } required := map[string]string{ - "TAPM_DATABASE_DSN": cfg.DatabaseDSN, "TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID, "TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret, "TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser, diff --git a/internal/app/download.go b/internal/app/download.go index 441c631..2714990 100644 --- a/internal/app/download.go +++ b/internal/app/download.go @@ -101,7 +101,7 @@ func (s *Server) exchangeRateLimited(r *http.Request, sourceIP string) (bool, er FROM audit_events WHERE event_type = 'code_exchange_failed' AND source_ip = ? - AND created_at > UTC_TIMESTAMP(6) - INTERVAL 10 MINUTE`, + AND created_at > datetime('now', '-10 minutes')`, sourceIP, ).Scan(&attempts) return attempts >= 10, err @@ -130,8 +130,7 @@ func (s *Server) exchangeDeploymentCode( FROM authorizations WHERE code_hash = ? AND revoked_at IS NULL - AND expires_at > UTC_TIMESTAMP(6) - FOR UPDATE`, + AND expires_at > CURRENT_TIMESTAMP`, codeHash[:], ).Scan(&authorizationID, &hostLimit, &expiresAt) if errors.Is(err, sql.ErrNoRows) { @@ -216,7 +215,7 @@ func (s *Server) exchangeDeploymentCode( r.Context(), `UPDATE authorization_hosts SET hostname = ?, - last_seen_at = UTC_TIMESTAMP(6) + last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, request.Hostname, hostID, ) @@ -342,9 +341,9 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) { JOIN packages p ON p.id = ap.package_id WHERE ds.token_hash = ? AND ds.revoked_at IS NULL - AND ds.expires_at > UTC_TIMESTAMP(6) + AND ds.expires_at > CURRENT_TIMESTAMP AND a.revoked_at IS NULL - AND a.expires_at > UTC_TIMESTAMP(6) + AND a.expires_at > CURRENT_TIMESTAMP AND p.slug = ? AND p.enabled = TRUE`, sessionHash[:], slug, diff --git a/internal/app/server.go b/internal/app/server.go index b69f49f..a44214e 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -17,7 +17,7 @@ import ( "strings" "time" - _ "github.com/go-sql-driver/mysql" + _ "modernc.org/sqlite" ) //go:embed templates/*.html static/* @@ -110,13 +110,15 @@ type pageData struct { } func New(cfg Config) (*Server, error) { - db, err := sql.Open("mysql", cfg.DatabaseDSN) + db, err := sql.Open("sqlite", cfg.DatabaseDSN) if err != nil { return nil, err } db.SetConnMaxLifetime(5 * time.Minute) - db.SetMaxOpenConns(20) - db.SetMaxIdleConns(10) + // A single writer connection avoids SQLITE_BUSY errors while WAL mode still + // permits concurrent readers. This service is intentionally single-node. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..890f2ed --- /dev/null +++ b/manage.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +DEPLOY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +cd "$DEPLOY_ROOT" + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +read_env_setting() { + local key="$1" + local value + value="$( + sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" .env | + tail -n 1 + )" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + value="${value#\"}" + value="${value%\"}" + value="${value#\'}" + value="${value%\'}" + printf '%s' "$value" +} + +require_env() { + [[ -f .env ]] || fail "copy .env.example to .env and configure it first" + BROKER_DOMAIN="$(read_env_setting BROKER_DOMAIN)" + GITEA_DOMAIN="$(read_env_setting GITEA_DOMAIN)" + LETSENCRYPT_EMAIL="$(read_env_setting LETSENCRYPT_EMAIL)" + : "${BROKER_DOMAIN:?BROKER_DOMAIN is required}" + : "${GITEA_DOMAIN:?GITEA_DOMAIN is required}" + : "${LETSENCRYPT_EMAIL:?LETSENCRYPT_EMAIL is required}" +} + +prepare() { + mkdir -p \ + config/broker \ + config/gitea/data \ + config/gitea/config \ + config/letsencrypt \ + config/certbot-webroot \ + config/backups + chmod 700 config/broker config/gitea/data config/gitea/config config/backups + if [[ "$(id -u)" == 0 ]]; then + chown -R 1000:1000 config/broker config/gitea + fi + docker network inspect tapm-edge >/dev/null 2>&1 || + docker network create tapm-edge >/dev/null +} + +gitea_compose() { + docker compose --env-file .env -f deploy/gitea/compose.yaml "$@" +} + +case "${1:-}" in +prepare) + require_env + prepare + ;; +bootstrap) + require_env + prepare + gitea_compose up -d + docker compose up -d nginx + docker compose --profile tools run --rm certbot certonly \ + --webroot --webroot-path /var/www/certbot \ + --non-interactive --agree-tos \ + --email "$LETSENCRYPT_EMAIL" \ + --cert-name "$BROKER_DOMAIN" \ + -d "$BROKER_DOMAIN" -d "$GITEA_DOMAIN" + docker compose -f compose.yaml -f compose.tls.yaml up -d nginx + ;; +start) + require_env + prepare + gitea_compose up -d + docker compose -f compose.yaml -f compose.tls.yaml up -d --build + ;; +renew) + require_env + docker compose --profile tools run --rm certbot renew --quiet + docker compose -f compose.yaml -f compose.tls.yaml exec nginx nginx -s reload + ;; +backup) + require_env + prepare + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + destination="config/backups/${stamp}" + mkdir -p "$destination" + docker compose stop broker + gitea_compose stop gitea + cp -a config/broker "$destination/" + cp -a config/gitea "$destination/" + gitea_compose start gitea + docker compose -f compose.yaml -f compose.tls.yaml start broker + printf 'Backup created at %s\n' "$destination" + ;; +status) + docker compose -f compose.yaml -f compose.tls.yaml ps + gitea_compose ps + ;; +*) + printf 'Usage: %s {prepare|bootstrap|start|renew|backup|status}\n' "$0" + exit 2 + ;; +esac diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql index 3927213..b4bbd88 100644 --- a/migrations/001_initial.sql +++ b/migrations/001_initial.sql @@ -1,103 +1,125 @@ -CREATE TABLE IF NOT EXISTS schema_migrations ( - version BIGINT UNSIGNED NOT NULL PRIMARY KEY, - applied_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) -) ENGINE=InnoDB; +CREATE TABLE technician_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash BLOB NOT NULL UNIQUE, + csrf_token TEXT NOT NULL, + gitea_login TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_technician_sessions_expires + ON technician_sessions(expires_at); -CREATE TABLE IF NOT EXISTS technician_sessions ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - token_hash BINARY(32) NOT NULL UNIQUE, - csrf_token VARCHAR(64) NOT NULL, - gitea_login VARCHAR(255) NOT NULL, - display_name VARCHAR(255) NOT NULL, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - expires_at TIMESTAMP(6) NOT NULL, - last_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - INDEX idx_technician_sessions_expires (expires_at) -) ENGINE=InnoDB; +CREATE TABLE packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + package_name TEXT NOT NULL, + package_version TEXT NOT NULL, + file_name TEXT NOT NULL, + sha256 TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); -CREATE TABLE IF NOT EXISTS packages ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - slug VARCHAR(100) NOT NULL UNIQUE, - display_name VARCHAR(255) NOT NULL, - package_name VARCHAR(255) NOT NULL, - package_version VARCHAR(100) NOT NULL, - file_name VARCHAR(255) NOT NULL, - sha256 CHAR(64) NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) - ON UPDATE CURRENT_TIMESTAMP(6) -) ENGINE=InnoDB; +CREATE TABLE authorizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash BLOB NOT NULL UNIQUE, + code_hint TEXT NOT NULL, + created_by TEXT NOT NULL, + customer_label TEXT NOT NULL DEFAULT '', + host_limit INTEGER NOT NULL CHECK (host_limit > 0), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + revoked_at DATETIME +); +CREATE INDEX idx_authorizations_expires ON authorizations(expires_at); +CREATE INDEX idx_authorizations_created_by + ON authorizations(created_by, created_at); -CREATE TABLE IF NOT EXISTS authorizations ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - code_hash BINARY(32) NOT NULL UNIQUE, - code_hint VARCHAR(8) NOT NULL, - created_by VARCHAR(255) NOT NULL, - customer_label VARCHAR(255) NOT NULL DEFAULT '', - host_limit INT UNSIGNED NOT NULL, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - expires_at TIMESTAMP(6) NOT NULL, - revoked_at TIMESTAMP(6) NULL, - INDEX idx_authorizations_expires (expires_at), - INDEX idx_authorizations_created_by (created_by, created_at) -) ENGINE=InnoDB; - -CREATE TABLE IF NOT EXISTS authorization_packages ( - authorization_id BIGINT UNSIGNED NOT NULL, - package_id BIGINT UNSIGNED NOT NULL, +CREATE TABLE authorization_packages ( + authorization_id INTEGER NOT NULL, + package_id INTEGER NOT NULL, + package_slug TEXT NOT NULL, + display_name TEXT NOT NULL, + package_name TEXT NOT NULL, + package_version TEXT NOT NULL, + file_name TEXT NOT NULL, + sha256 TEXT NOT NULL, PRIMARY KEY (authorization_id, package_id), - CONSTRAINT fk_authorization_packages_authorization - FOREIGN KEY (authorization_id) REFERENCES authorizations(id) + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) ON DELETE CASCADE, - CONSTRAINT fk_authorization_packages_package - FOREIGN KEY (package_id) REFERENCES packages(id) + FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE RESTRICT -) ENGINE=InnoDB; +); -CREATE TABLE IF NOT EXISTS authorization_hosts ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - authorization_id BIGINT UNSIGNED NOT NULL, - host_fingerprint BINARY(32) NOT NULL, - hostname VARCHAR(255) NOT NULL, - first_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - last_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - UNIQUE KEY uq_authorization_host (authorization_id, host_fingerprint), - CONSTRAINT fk_authorization_hosts_authorization - FOREIGN KEY (authorization_id) REFERENCES authorizations(id) +CREATE TABLE authorization_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + authorization_id INTEGER NOT NULL, + host_fingerprint BLOB NOT NULL, + hostname TEXT NOT NULL, + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (authorization_id, host_fingerprint), + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) ON DELETE CASCADE -) ENGINE=InnoDB; +); -CREATE TABLE IF NOT EXISTS download_sessions ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - token_hash BINARY(32) NOT NULL UNIQUE, - authorization_id BIGINT UNSIGNED NOT NULL, - authorization_host_id BIGINT UNSIGNED NOT NULL, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - expires_at TIMESTAMP(6) NOT NULL, - revoked_at TIMESTAMP(6) NULL, - INDEX idx_download_sessions_expires (expires_at), - CONSTRAINT fk_download_sessions_authorization - FOREIGN KEY (authorization_id) REFERENCES authorizations(id) +CREATE TABLE download_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash BLOB NOT NULL UNIQUE, + authorization_id INTEGER NOT NULL, + authorization_host_id INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + revoked_at DATETIME, + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) ON DELETE CASCADE, - CONSTRAINT fk_download_sessions_host - FOREIGN KEY (authorization_host_id) REFERENCES authorization_hosts(id) + FOREIGN KEY (authorization_host_id) REFERENCES authorization_hosts(id) ON DELETE CASCADE -) ENGINE=InnoDB; +); +CREATE INDEX idx_download_sessions_expires ON download_sessions(expires_at); -CREATE TABLE IF NOT EXISTS audit_events ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - event_type VARCHAR(100) NOT NULL, - actor VARCHAR(255) NOT NULL DEFAULT '', - authorization_id BIGINT UNSIGNED NULL, - hostname VARCHAR(255) NOT NULL DEFAULT '', - package_slug VARCHAR(100) NOT NULL DEFAULT '', - source_ip VARCHAR(64) NOT NULL DEFAULT '', +CREATE TABLE audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + authorization_id INTEGER, + hostname TEXT NOT NULL DEFAULT '', + package_slug TEXT NOT NULL DEFAULT '', + source_ip TEXT NOT NULL DEFAULT '', details TEXT NOT NULL, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - INDEX idx_audit_created (created_at), - INDEX idx_audit_authorization (authorization_id, created_at), - INDEX idx_audit_exchange_limit (event_type, source_ip, created_at) -) ENGINE=InnoDB; + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_audit_created ON audit_events(created_at); +CREATE INDEX idx_audit_authorization + ON audit_events(authorization_id, created_at); +CREATE INDEX idx_audit_exchange_limit + ON audit_events(event_type, source_ip, created_at); -INSERT IGNORE INTO schema_migrations (version) VALUES (1); +CREATE TABLE installer_actions ( + slug TEXT NOT NULL PRIMARY KEY, + display_name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE authorization_actions ( + authorization_id INTEGER NOT NULL, + action_slug TEXT NOT NULL, + PRIMARY KEY (authorization_id, action_slug), + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) + ON DELETE CASCADE, + FOREIGN KEY (action_slug) REFERENCES installer_actions(slug) + ON DELETE RESTRICT +); + +INSERT INTO installer_actions (slug, display_name, sort_order, enabled) +VALUES + ('install-rmm', 'Install ConnectWise RMM agent', 10, 1), + ('install-acronis', 'Install Acronis agent', 20, 1), + ('install-screenconnect', 'Install ScreenConnect agent', 30, 1); diff --git a/migrations/002_installer_actions.sql b/migrations/002_installer_actions.sql deleted file mode 100644 index c0b99a0..0000000 --- a/migrations/002_installer_actions.sql +++ /dev/null @@ -1,58 +0,0 @@ -CREATE TABLE IF NOT EXISTS installer_actions ( - slug VARCHAR(100) NOT NULL PRIMARY KEY, - display_name VARCHAR(255) NOT NULL, - sort_order INT UNSIGNED NOT NULL DEFAULT 0, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) - ON UPDATE CURRENT_TIMESTAMP(6) -) ENGINE=InnoDB; - -CREATE TABLE IF NOT EXISTS authorization_actions ( - authorization_id BIGINT UNSIGNED NOT NULL, - action_slug VARCHAR(100) NOT NULL, - PRIMARY KEY (authorization_id, action_slug), - CONSTRAINT fk_authorization_actions_authorization - FOREIGN KEY (authorization_id) REFERENCES authorizations(id) - ON DELETE CASCADE, - CONSTRAINT fk_authorization_actions_action - FOREIGN KEY (action_slug) REFERENCES installer_actions(slug) - ON DELETE RESTRICT -) ENGINE=InnoDB; - -ALTER TABLE authorization_packages - ADD COLUMN IF NOT EXISTS package_slug VARCHAR(100) NULL, - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NULL, - ADD COLUMN IF NOT EXISTS package_name VARCHAR(255) NULL, - ADD COLUMN IF NOT EXISTS package_version VARCHAR(100) NULL, - ADD COLUMN IF NOT EXISTS file_name VARCHAR(255) NULL, - ADD COLUMN IF NOT EXISTS sha256 CHAR(64) NULL; - -UPDATE authorization_packages ap -JOIN packages p ON p.id = ap.package_id -SET ap.package_slug = p.slug, - ap.display_name = p.display_name, - ap.package_name = p.package_name, - ap.package_version = p.package_version, - ap.file_name = p.file_name, - ap.sha256 = p.sha256 -WHERE ap.package_slug IS NULL; - -ALTER TABLE authorization_packages - MODIFY package_slug VARCHAR(100) NOT NULL, - MODIFY display_name VARCHAR(255) NOT NULL, - MODIFY package_name VARCHAR(255) NOT NULL, - MODIFY package_version VARCHAR(100) NOT NULL, - MODIFY file_name VARCHAR(255) NOT NULL, - MODIFY sha256 CHAR(64) NOT NULL; - -INSERT INTO installer_actions (slug, display_name, sort_order, enabled) -VALUES - ('install-rmm', 'Install ConnectWise RMM agent', 10, TRUE), - ('install-acronis', 'Install Acronis agent', 20, TRUE), - ('install-screenconnect', 'Install ScreenConnect agent', 30, TRUE) -ON DUPLICATE KEY UPDATE - display_name = VALUES(display_name), - sort_order = VALUES(sort_order); - -INSERT IGNORE INTO schema_migrations (version) VALUES (2); diff --git a/migrations/003_audit_network.sql b/migrations/003_audit_network.sql deleted file mode 100644 index a876724..0000000 --- a/migrations/003_audit_network.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE authorization_hosts - ADD COLUMN IF NOT EXISTS lan_ip VARCHAR(64) NOT NULL DEFAULT '' - AFTER hostname; - -ALTER TABLE audit_events - ADD COLUMN IF NOT EXISTS lan_ip VARCHAR(64) NOT NULL DEFAULT '' - AFTER source_ip; - -INSERT IGNORE INTO schema_migrations (version) VALUES (3); diff --git a/migrations/004_remove_lan_ip.sql b/migrations/004_remove_lan_ip.sql deleted file mode 100644 index da33a77..0000000 --- a/migrations/004_remove_lan_ip.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE authorization_hosts - DROP COLUMN IF EXISTS lan_ip; - -ALTER TABLE audit_events - DROP COLUMN IF EXISTS lan_ip; - -INSERT IGNORE INTO schema_migrations (version) VALUES (4); diff --git a/update.sh b/update.sh deleted file mode 100755 index 8d3baae..0000000 --- a/update.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -SCRIPT_PATH="${SCRIPT_DIR}/$(basename -- "${BASH_SOURCE[0]}")" - -COLOR_RESET='' -COLOR_RED='' -COLOR_GREEN='' -COLOR_CYAN='' -ERROR_RESET='' -ERROR_RED='' -ERROR_YELLOW='' -if [[ -z "${NO_COLOR:-}" ]]; then - if [[ -t 1 ]]; then - COLOR_RESET=$'\033[0m' - COLOR_RED=$'\033[1;31m' - COLOR_GREEN=$'\033[1;32m' - COLOR_CYAN=$'\033[1;36m' - fi - if [[ -t 2 ]]; then - ERROR_RESET=$'\033[0m' - ERROR_RED=$'\033[1;31m' - ERROR_YELLOW=$'\033[1;33m' - fi -fi - -fail() { - printf '%sERROR:%s %s\n' "$ERROR_RED" "$ERROR_RESET" "$*" >&2 - exit 1 -} - -trap 'printf "%sERROR:%s broker update failed at line %d.\n" "$ERROR_RED" "$ERROR_RESET" "$LINENO" >&2' ERR - -cd "$SCRIPT_DIR" - -command -v git >/dev/null 2>&1 || fail "git is required" -LOCAL_HOSTNAME="$(hostname -s 2>/dev/null || printf 'localhost')" - -POST_PULL=0 -LOCAL_ONLY="${TAPM_UPDATE_LOCAL_ONLY:-0}" -for argument in "$@"; do - case "$argument" in - --post-pull) POST_PULL=1 ;; - --local-only) LOCAL_ONLY=1 ;; - *) fail "unknown argument: ${argument}" ;; - esac -done - -deploy_local_broker() { - command -v docker >/dev/null 2>&1 || fail "docker is required" - docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is required" - [[ -f compose.yaml ]] || fail "compose.yaml was not found in ${SCRIPT_DIR}" - [[ -f .env ]] || fail ".env was not found in ${SCRIPT_DIR}" - - printf 'Updating local broker at %s%s%s...\n' \ - "$COLOR_CYAN" "$LOCAL_HOSTNAME" "$COLOR_RESET" - - printf 'Building broker image...\n' - docker compose build --quiet broker - - printf 'Applying pending database migrations...\n' - docker compose run --rm migrate - - printf 'Starting broker...\n' - docker compose up -d broker - - container_id="$(docker compose ps -q broker)" - [[ -n "$container_id" ]] || fail "Docker Compose did not return a broker container" - - printf 'Waiting for broker health check' - for ((attempt = 1; attempt <= 75; attempt++)); do - status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" - case "$status" in - healthy) - printf ' %shealthy%s.\n' "$COLOR_GREEN" "$COLOR_RESET" - docker compose ps broker - printf '%sSUCCESS:%s Local TAPM Deployment Broker at %s%s%s updated successfully.\n' \ - "$COLOR_GREEN" "$COLOR_RESET" \ - "$COLOR_CYAN" "$LOCAL_HOSTNAME" "$COLOR_RESET" - return 0 - ;; - exited | dead | unhealthy) - printf ' %s%s%s.\n' "$COLOR_RED" "$status" "$COLOR_RESET" - docker compose logs --tail=100 broker - fail "broker failed its health check" - ;; - esac - printf '.' - sleep 2 - done - - printf ' timed out.\n' - docker compose logs --tail=100 broker - fail "broker did not become healthy within 150 seconds" -} - -read_env_setting() { - local key="$1" - local value - - value="$( - sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" .env | - tail -n 1 - )" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - value="${value#\"}" - value="${value%\"}" - value="${value#\'}" - value="${value%\'}" - printf '%s' "$value" -} - -update_peer_broker() { - local peer_list="${TAPM_UPDATE_PEERS:-}" - local ssh_user="${TAPM_UPDATE_SSH_USER:-}" - local remote_update_path='/opt/idssys/ta-deployment-broker/update.sh' - local peer_host - local raw_peer - local -a peer_hosts - local -a ssh_options=( - -o BatchMode=yes - -o ConnectTimeout=5 - -o ConnectionAttempts=1 - -o StrictHostKeyChecking=yes - ) - - [[ -f .env ]] || fail ".env was not found in ${SCRIPT_DIR}" - if [[ -z "$peer_list" ]]; then - peer_list="$(read_env_setting TAPM_UPDATE_PEERS)" - fi - [[ -n "$peer_list" ]] || - fail "TAPM_UPDATE_PEERS is not configured in .env" - if [[ -z "$ssh_user" ]]; then - ssh_user="$(read_env_setting TAPM_UPDATE_SSH_USER)" - fi - ssh_user="${ssh_user:-root}" - - IFS=',' read -r -a peer_hosts <<< "$peer_list" - for raw_peer in "${peer_hosts[@]}"; do - peer_host="${raw_peer#"${raw_peer%%[![:space:]]*}"}" - peer_host="${peer_host%"${peer_host##*[![:space:]]}"}" - [[ -n "$peer_host" ]] || continue - - printf 'Checking peer broker at %s%s%s...\n' \ - "$COLOR_CYAN" "$peer_host" "$COLOR_RESET" - if ! ssh "${ssh_options[@]}" "${ssh_user}@${peer_host}" true; then - printf '%sWARNING:%s peer %s is unavailable over SSH; its update was skipped.\n' \ - "$ERROR_YELLOW" "$ERROR_RESET" "$peer_host" >&2 - continue - fi - if ! ssh "${ssh_options[@]}" "${ssh_user}@${peer_host}" \ - "TAPM_UPDATE_LOCAL_ONLY=1 ${remote_update_path} --local-only"; then - fail "peer ${peer_host} is reachable, but its broker update failed" - fi - printf '%sSUCCESS:%s Peer broker at %s%s%s completed successfully.\n' \ - "$COLOR_GREEN" "$COLOR_RESET" "$COLOR_CYAN" "$peer_host" "$COLOR_RESET" - done -} - -if ((POST_PULL == 0)); then - branch="$(git symbolic-ref --quiet --short HEAD)" || - fail "the repository is in detached HEAD state" - upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" || - fail "branch ${branch} does not have an upstream branch" - remote="$(git config --get "branch.${branch}.remote")" || - fail "branch ${branch} does not have a configured remote" - git diff --quiet && git diff --cached --quiet || - fail "tracked local changes must be committed or stashed before updating" - - printf 'Checking %s against %s...\n' "$branch" "$upstream" - git fetch --quiet --prune "$remote" - - local_commit="$(git rev-parse HEAD)" - upstream_commit="$(git rev-parse '@{upstream}')" - if [[ "$local_commit" == "$upstream_commit" ]]; then - printf '%sLocal TAPM Deployment Broker is already up to date.%s\n' \ - "$COLOR_GREEN" "$COLOR_RESET" - elif git merge-base --is-ancestor "$local_commit" "$upstream_commit"; then - printf 'Updating TAPM Deployment Broker from %s...\n' "$upstream" - git merge --quiet --ff-only "$upstream_commit" - - # Restart from the newly pulled script so updates to this file take effect. - if ((LOCAL_ONLY == 1)); then - exec "$SCRIPT_PATH" --post-pull --local-only - fi - exec "$SCRIPT_PATH" --post-pull - elif git merge-base --is-ancestor "$upstream_commit" "$local_commit"; then - fail "local branch ${branch} is ahead of ${upstream}; refusing to rebuild" - else - fail "local branch ${branch} has diverged from ${upstream}" - fi -else - deploy_local_broker -fi - -if ((LOCAL_ONLY == 0)); then - update_peer_broker -fi - -printf '%sTAPM Deployment Broker update check completed.%s\n' \ - "$COLOR_GREEN" "$COLOR_RESET"