switched to fully self contained docker

This commit is contained in:
2026-07-26 14:01:20 -05:00
parent 0ed0fb5817
commit 5d2f90ce24
34 changed files with 688 additions and 672 deletions
+4
View File
@@ -4,3 +4,7 @@
README.md README.md
docs docs
deploy deploy
config
main
compose*.yaml
manage.sh
+14 -11
View File
@@ -1,18 +1,21 @@
TAPM_LISTEN_ADDR=0.0.0.0:8080 BROKER_DOMAIN=broker.example.com
TAPM_UPDATE_PEERS=10.10.1.122 GITEA_DOMAIN=git.example.com
TAPM_UPDATE_SSH_USER=root LETSENCRYPT_EMAIL=admin@example.com
TAPM_PUBLIC_URL=https://tapm.scity.us GITEA_SSH_PORT=2222
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_LISTEN_ADDR=:8080
TAPM_GITEA_CLIENT_ID=replace-me TAPM_DATABASE_DSN=file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
TAPM_GITEA_CLIENT_SECRET=replace-me 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_OWNER=TAI
TAPM_GITEA_PACKAGE_USERNAME=tapm-packages 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_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_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_DURATION=3h
TAPM_DEFAULT_HOST_LIMIT=3 TAPM_DEFAULT_HOST_LIMIT=3
TAPM_MAX_HOST_LIMIT=25 TAPM_MAX_HOST_LIMIT=25
+6 -3
View File
@@ -10,12 +10,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tapm-se
FROM alpine:3.22 FROM alpine:3.22
RUN apk add --no-cache ca-certificates \ RUN apk add --no-cache ca-certificates \
&& addgroup -S tapm \ && addgroup -S -g 1000 tapm \
&& adduser -S -G tapm -H tapm && adduser -S -u 1000 -G tapm -H tapm \
&& mkdir -p /data \
&& chown tapm:tapm /data
WORKDIR /app WORKDIR /app
COPY --from=build /out/tapm-server /usr/local/bin/tapm-server COPY --from=build /out/tapm-server /usr/local/bin/tapm-server
COPY --from=build /out/tapm-migrate /usr/local/bin/tapm-migrate COPY --from=build /out/tapm-migrate /usr/local/bin/tapm-migrate
COPY migrations /app/migrations COPY migrations /app/migrations
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint
USER tapm USER tapm
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/tapm-server"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint"]
+17 -22
View File
@@ -1,35 +1,30 @@
# TAPM Deployment Access # TAPM Deployment Access
TAPM Deployment Access is a short-lived authorization broker for protected TAPM Deployment Access is a short-lived authorization broker for protected
deployment packages and installer actions. Technicians authenticate with deployment packages and installer actions. Technicians authenticate through
Gitea, create a deployment code, and permit a limited number of hosts to use the bundled Gitea service, create deployment codes, and authorize a limited
explicitly selected capabilities during a fixed authorization window. 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` - Go broker with a persistent embedded SQLite database
- Authorization lifetime: 3 hours - rootless Gitea with SQLite, repositories, and package registry
- Host limit: 3 - Nginx reverse proxy for both public domains
- Authentication: Gitea OAuth - Certbot and Let's Encrypt HTTP-01 certificate management
- Shared state: MariaDB Galera - explicit runtime storage under `config/` beside the Compose files
- Package storage: private Gitea Generic Package Registry
See [docs/deployment.md](docs/deployment.md) for installation and configuration. No external database or load balancer is required. The datacenter edge only
The ProxMenu integration contract is documented in needs to forward TCP 80, 443, and 2222 to the Docker VM.
[docs/client-api.md](docs/client-api.md).
## Components See [docs/deployment.md](docs/deployment.md) for the installation, Gitea setup,
TLS bootstrap, repository move, renewal, backup, and update procedures. The
- `cmd/server`: portal, OAuth flow, authorization API, uploads, and downloads ProxMenu integration contract is in [docs/client-api.md](docs/client-api.md).
- `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
## Local checks ## Local checks
```sh ```sh
docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine \ GOCACHE=/tmp/tapm-go-cache go test ./...
sh -c 'gofmt -w cmd internal && 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 . docker build -t tai/tapm-deployment-broker:local .
``` ```
+24 -22
View File
@@ -12,44 +12,35 @@ import (
"strings" "strings"
"time" "time"
_ "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite"
) )
func main() { func main() {
dsn := os.Getenv("TAPM_DATABASE_DSN") dsn := os.Getenv("TAPM_DATABASE_DSN")
if 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") directory := os.Getenv("TAPM_MIGRATIONS_DIR")
if directory == "" { if directory == "" {
directory = "/app/migrations" directory = "/app/migrations"
} }
db, err := sql.Open("mysql", dsn) db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer db.Close() defer db.Close()
db.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
if err := db.PingContext(ctx); err != nil { if err := db.PingContext(ctx); err != nil {
log.Fatalf("database: %v", err) 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 ( if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT UNSIGNED NOT NULL PRIMARY KEY, version INTEGER NOT NULL PRIMARY KEY,
applied_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB`); err != nil { )`); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -71,24 +62,35 @@ func main() {
log.Fatalf("migration %q: %v", entry.Name(), err) log.Fatalf("migration %q: %v", entry.Name(), err)
} }
var applied int var applied int
if err := db.QueryRowContext( if err := db.QueryRowContext(ctx,
ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version,
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`,
version,
).Scan(&applied); err != nil { ).Scan(&applied); err != nil {
log.Fatal(err) log.Fatal(err)
} }
if applied != 0 { if applied != 0 {
continue continue
} }
body, err := os.ReadFile(filepath.Join(directory, entry.Name())) body, err := os.ReadFile(filepath.Join(directory, entry.Name()))
if err != nil { if err != nil {
log.Fatal(err) 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) 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()) fmt.Printf("applied migration %s\n", entry.Name())
} }
} }
+4
View File
@@ -0,0 +1,4 @@
services:
nginx:
volumes:
- ./deploy/nginx/templates/tls.conf.template:/etc/nginx/templates/default.conf.template:ro
+40 -12
View File
@@ -1,20 +1,19 @@
services: name: tapm
migrate:
build: .
image: tai/tapm-deployment-broker:local
entrypoint: ["/usr/local/bin/tapm-migrate"]
env_file: .env
network_mode: host
restart: "no"
services:
broker: broker:
build: . build: .
image: tai/tapm-deployment-broker:local image: tai/tapm-deployment-broker:local
env_file: .env env_file:
network_mode: host - path: .env
required: false
restart: unless-stopped restart: unless-stopped
init: true init: true
read_only: true read_only: true
networks:
- edge
volumes:
- ./config/broker:/data
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
@@ -22,8 +21,37 @@ services:
tmpfs: tmpfs:
- /tmp:size=16m,mode=1777 - /tmp:size=16m,mode=1777
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 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
+17
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+40
View File
@@ -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
-23
View File
@@ -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;
}
}
@@ -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;
}
}
+76
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
/usr/local/bin/tapm-migrate
exec /usr/local/bin/tapm-server
+92 -166
View File
@@ -1,221 +1,147 @@
# Deployment # Single-VM deployment
This service runs active-active on both webserver VMs. Both installations use The production deployment lives at:
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.
## 1. DNS and TLS ```text
/opt/idssys/TA-Deployment-Access
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
``` ```
The wildcard bind accepts connections through each node's physical address and It consists of four containers:
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`.
An example virtual host is in - TAPM broker, with an embedded SQLite database
[`deploy/nginx/tapm.scity.us.conf`](../deploy/nginx/tapm.scity.us.conf). - Gitea, with its own embedded SQLite database and repository storage
Adjust only the certificate paths if the local convention differs. - 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 ## 1. VM and network
addresses. Run this once against the cluster as a database administrator,
replacing the password with the same locally generated value in both accounts:
```sql Install Docker Engine with Compose v2. Permit inbound TCP 80, 443, and 2222.
CREATE DATABASE tapm_broker Forward those ports from the datacenter edge to this VM. Create public DNS A/AAAA
CHARACTER SET utf8mb4 records for both application names before requesting the certificate.
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;
```
The Keepalived address `10.10.1.120` is not included because outbound HAProxy Port 80 must remain reachable for HTTP-01 certificate renewal. If IPv6 is
connections normally originate from the physical webserver address. Add a published, it must reach this same VM.
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.
## 3. Gitea accounts and OAuth ## 2. Install the repository
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:
```sh ```sh
git clone https://git.scity.us/TAI/TA-Deployment-Broker.git sudo mkdir -p /opt/idssys
cd TA-Deployment-Broker 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 cp .env.example .env
chmod 600 .env chmod 600 .env
``` ```
Edit `.env` and use the same secrets on both webservers. The listen address is Set `BROKER_DOMAIN`, `GITEA_DOMAIN`, and `LETSENCRYPT_EMAIL` first. Replace all
the one node-specific value. Generate the cookie secret with: example domains in the TAPM variables. Generate the cookie secret with:
```sh ```sh
openssl rand -base64 48 openssl rand -base64 48
``` ```
The database DSN must retain `parseTime=true`, `multiStatements=true`, and the Runtime state is deliberately visible below the checkout:
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.
## 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 ```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 ```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 Sign in to Gitea at `https://GITEA_DOMAIN`, create the `TAI` organization, and
cannot apply the same migration twice. 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 ```sh
docker compose up -d broker ./manage.sh start
curl --fail "http://127.0.0.1:8080/health/ready" ./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 ```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 Update developer remotes, CI credentials, submodules, documentation, and the Go
`TAPM_UPDATE_SSH_USER` to each comma-separated host in `TAPM_UPDATE_PEERS` and module path only if the hostname embedded in the module path is also changing.
performs the same local-only check there. This provides a sequential rolling Move the deployment repository last so this checkout remains updateable during
update from any node. An unreachable peer is skipped with a warning; a reachable the transition.
peer whose update fails causes the command to fail. The SSH user defaults to
`root` when omitted.
Configure each webserver's `.env` with its other broker: ## 6. Renewal and backups
```dotenv Run renewal twice daily from root's crontab:
# Webserver-Node1
TAPM_UPDATE_PEERS=10.10.1.122
TAPM_UPDATE_SSH_USER=root
# Webserver-Node2 ```cron
TAPM_UPDATE_PEERS=10.10.1.121 17 3,15 * * * cd /opt/idssys/TA-Deployment-Access && ./manage.sh renew
TAPM_UPDATE_SSH_USER=root
``` ```
Additional peers can be listed with commas. The updater reads only these named Create an application-consistent local snapshot with:
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:
```sh ```sh
curl --user 'publisher:PACKAGE_WRITE_TOKEN' \ ./manage.sh backup
--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'
``` ```
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 ```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 Pin image versions as supplied and review release notes before changing them.
manually**, enter the matching values, and enable it. The broker never uses the Never change Gitea between rootless and rootful image families in place.
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.
+13 -2
View File
@@ -2,6 +2,17 @@ module git.scity.us/TAI/TA-Deployment-Broker
go 1.24 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
)
+49 -4
View File
@@ -1,4 +1,49 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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=
+2 -2
View File
@@ -216,7 +216,7 @@ func (s *Server) currentTechnician(r *http.Request) (*technician, error) {
r.Context(), r.Context(),
`SELECT gitea_login, display_name, csrf_token `SELECT gitea_login, display_name, csrf_token
FROM technician_sessions FROM technician_sessions
WHERE token_hash = ? AND expires_at > UTC_TIMESTAMP(6)`, WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`,
tokenHash[:], tokenHash[:],
).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken) ).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken)
if err != nil { if err != nil {
@@ -224,7 +224,7 @@ func (s *Server) currentTechnician(r *http.Request) (*technician, error) {
} }
_, _ = s.db.ExecContext( _, _ = s.db.ExecContext(
r.Context(), r.Context(),
`UPDATE technician_sessions SET last_seen_at = UTC_TIMESTAMP(6) `UPDATE technician_sessions SET last_seen_at = CURRENT_TIMESTAMP
WHERE token_hash = ?`, WHERE token_hash = ?`,
tokenHash[:], tokenHash[:],
) )
+23 -20
View File
@@ -215,10 +215,10 @@ func auditQuery(filters auditFilters, limit int) (string, []any) {
var arguments []any var arguments []any
timeClauses := map[string]string{ timeClauses := map[string]string{
"24h": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY", "24h": " AND ae.created_at >= datetime('now', '-1 day')",
"7d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY", "7d": " AND ae.created_at >= datetime('now', '-7 days')",
"30d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY", "30d": " AND ae.created_at >= datetime('now', '-30 days')",
"90d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY", "90d": " AND ae.created_at >= datetime('now', '-90 days')",
} }
query += timeClauses[filters.TimeRange] query += timeClauses[filters.TimeRange]
@@ -240,11 +240,11 @@ func auditQuery(filters auditFilters, limit int) (string, []any) {
if filter.value == "" { if filter.value == "" {
continue continue
} }
query += " AND LOCATE(?, " + filter.column + ") > 0" query += " AND instr(" + filter.column + ", ?) > 0"
arguments = append(arguments, filter.value) arguments = append(arguments, filter.value)
} }
if filters.SourceIP != "" { if filters.SourceIP != "" {
query += " AND LOCATE(?, ae.source_ip) > 0" query += " AND instr(ae.source_ip, ?) > 0"
arguments = append(arguments, filters.SourceIP) arguments = append(arguments, filters.SourceIP)
} }
query += " ORDER BY ae.created_at DESC LIMIT ?" 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), WHERE h.authorization_id = a.id),
a.created_at, a.expires_at, a.revoked_at, a.created_at, a.expires_at, a.revoked_at,
COALESCE(( COALESCE((
SELECT GROUP_CONCAT(p.display_name SELECT GROUP_CONCAT(display_name, ', ')
ORDER BY p.display_name SEPARATOR ', ') FROM (SELECT p.display_name
FROM authorization_packages ap FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = a.id WHERE ap.authorization_id = a.id
ORDER BY p.display_name)
), ''), ), ''),
COALESCE(( COALESCE((
SELECT GROUP_CONCAT(ia.display_name SELECT GROUP_CONCAT(display_name, ', ')
ORDER BY ia.sort_order, ia.display_name SEPARATOR ', ') FROM (SELECT ia.display_name
FROM authorization_actions aa FROM authorization_actions aa
JOIN installer_actions ia ON ia.slug = aa.action_slug JOIN installer_actions ia ON ia.slug = aa.action_slug
WHERE aa.authorization_id = a.id WHERE aa.authorization_id = a.id
ORDER BY ia.sort_order, ia.display_name)
), '') ), '')
FROM authorizations a 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 ORDER BY a.created_at DESC
LIMIT 100`, LIMIT 100`,
) )
@@ -489,7 +491,7 @@ func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Reques
result, err := s.db.ExecContext( result, err := s.db.ExecContext(
r.Context(), r.Context(),
`UPDATE authorizations `UPDATE authorizations
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(6)) SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
WHERE id = ?`, WHERE id = ?`,
id, id,
) )
@@ -504,7 +506,7 @@ func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Reques
} }
_, _ = s.db.ExecContext( _, _ = s.db.ExecContext(
r.Context(), 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`, WHERE authorization_id = ? AND revoked_at IS NULL`,
id, id,
) )
@@ -568,13 +570,14 @@ func (s *Server) savePackage(
`INSERT INTO packages `INSERT INTO packages
(slug, display_name, package_name, package_version, file_name, sha256, enabled) (slug, display_name, package_name, package_version, file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON CONFLICT(slug) DO UPDATE SET
display_name = VALUES(display_name), display_name = excluded.display_name,
package_name = VALUES(package_name), package_name = excluded.package_name,
package_version = VALUES(package_version), package_version = excluded.package_version,
file_name = VALUES(file_name), file_name = excluded.file_name,
sha256 = VALUES(sha256), sha256 = excluded.sha256,
enabled = VALUES(enabled)`, enabled = excluded.enabled,
updated_at = CURRENT_TIMESTAMP`,
slug, slug,
displayName, displayName,
packageName, packageName,
+4 -2
View File
@@ -37,7 +37,10 @@ func LoadConfig() (Config, error) {
var err error var err error
cfg.ListenAddr = envDefault("TAPM_LISTEN_ADDR", ":8080") 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.GiteaClientID = os.Getenv("TAPM_GITEA_CLIENT_ID")
cfg.GiteaClientSecret = os.Getenv("TAPM_GITEA_CLIENT_SECRET") cfg.GiteaClientSecret = os.Getenv("TAPM_GITEA_CLIENT_SECRET")
cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI") cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI")
@@ -103,7 +106,6 @@ func LoadConfig() (Config, error) {
} }
required := map[string]string{ required := map[string]string{
"TAPM_DATABASE_DSN": cfg.DatabaseDSN,
"TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID, "TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID,
"TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret, "TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret,
"TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser, "TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser,
+5 -6
View File
@@ -101,7 +101,7 @@ func (s *Server) exchangeRateLimited(r *http.Request, sourceIP string) (bool, er
FROM audit_events FROM audit_events
WHERE event_type = 'code_exchange_failed' WHERE event_type = 'code_exchange_failed'
AND source_ip = ? AND source_ip = ?
AND created_at > UTC_TIMESTAMP(6) - INTERVAL 10 MINUTE`, AND created_at > datetime('now', '-10 minutes')`,
sourceIP, sourceIP,
).Scan(&attempts) ).Scan(&attempts)
return attempts >= 10, err return attempts >= 10, err
@@ -130,8 +130,7 @@ func (s *Server) exchangeDeploymentCode(
FROM authorizations FROM authorizations
WHERE code_hash = ? WHERE code_hash = ?
AND revoked_at IS NULL AND revoked_at IS NULL
AND expires_at > UTC_TIMESTAMP(6) AND expires_at > CURRENT_TIMESTAMP`,
FOR UPDATE`,
codeHash[:], codeHash[:],
).Scan(&authorizationID, &hostLimit, &expiresAt) ).Scan(&authorizationID, &hostLimit, &expiresAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -216,7 +215,7 @@ func (s *Server) exchangeDeploymentCode(
r.Context(), r.Context(),
`UPDATE authorization_hosts `UPDATE authorization_hosts
SET hostname = ?, SET hostname = ?,
last_seen_at = UTC_TIMESTAMP(6) last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`, WHERE id = ?`,
request.Hostname, hostID, 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 JOIN packages p ON p.id = ap.package_id
WHERE ds.token_hash = ? WHERE ds.token_hash = ?
AND ds.revoked_at IS NULL 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.revoked_at IS NULL
AND a.expires_at > UTC_TIMESTAMP(6) AND a.expires_at > CURRENT_TIMESTAMP
AND p.slug = ? AND p.slug = ?
AND p.enabled = TRUE`, AND p.enabled = TRUE`,
sessionHash[:], slug, sessionHash[:], slug,
+6 -4
View File
@@ -17,7 +17,7 @@ import (
"strings" "strings"
"time" "time"
_ "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite"
) )
//go:embed templates/*.html static/* //go:embed templates/*.html static/*
@@ -110,13 +110,15 @@ type pageData struct {
} }
func New(cfg Config) (*Server, error) { func New(cfg Config) (*Server, error) {
db, err := sql.Open("mysql", cfg.DatabaseDSN) db, err := sql.Open("sqlite", cfg.DatabaseDSN)
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.SetConnMaxLifetime(5 * time.Minute) db.SetConnMaxLifetime(5 * time.Minute)
db.SetMaxOpenConns(20) // A single writer connection avoids SQLITE_BUSY errors while WAL mode still
db.SetMaxIdleConns(10) // permits concurrent readers. This service is intentionally single-node.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
Executable
+110
View File
@@ -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
+106 -84
View File
@@ -1,103 +1,125 @@
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE technician_sessions (
version BIGINT UNSIGNED NOT NULL PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
applied_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) token_hash BLOB NOT NULL UNIQUE,
) ENGINE=InnoDB; 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 ( CREATE TABLE packages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash BINARY(32) NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
csrf_token VARCHAR(64) NOT NULL, display_name TEXT NOT NULL,
gitea_login VARCHAR(255) NOT NULL, package_name TEXT NOT NULL,
display_name VARCHAR(255) NOT NULL, package_version TEXT NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), file_name TEXT NOT NULL,
expires_at TIMESTAMP(6) NOT NULL, sha256 TEXT NOT NULL,
last_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), enabled INTEGER NOT NULL DEFAULT 0,
INDEX idx_technician_sessions_expires (expires_at) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
) ENGINE=InnoDB; updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS packages ( CREATE TABLE authorizations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug VARCHAR(100) NOT NULL UNIQUE, code_hash BLOB NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL, code_hint TEXT NOT NULL,
package_name VARCHAR(255) NOT NULL, created_by TEXT NOT NULL,
package_version VARCHAR(100) NOT NULL, customer_label TEXT NOT NULL DEFAULT '',
file_name VARCHAR(255) NOT NULL, host_limit INTEGER NOT NULL CHECK (host_limit > 0),
sha256 CHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
enabled BOOLEAN NOT NULL DEFAULT FALSE, expires_at DATETIME NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), revoked_at DATETIME
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) );
ON UPDATE CURRENT_TIMESTAMP(6) CREATE INDEX idx_authorizations_expires ON authorizations(expires_at);
) ENGINE=InnoDB; CREATE INDEX idx_authorizations_created_by
ON authorizations(created_by, created_at);
CREATE TABLE IF NOT EXISTS authorizations ( CREATE TABLE authorization_packages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, authorization_id INTEGER NOT NULL,
code_hash BINARY(32) NOT NULL UNIQUE, package_id INTEGER NOT NULL,
code_hint VARCHAR(8) NOT NULL, package_slug TEXT NOT NULL,
created_by VARCHAR(255) NOT NULL, display_name TEXT NOT NULL,
customer_label VARCHAR(255) NOT NULL DEFAULT '', package_name TEXT NOT NULL,
host_limit INT UNSIGNED NOT NULL, package_version TEXT NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), file_name TEXT NOT NULL,
expires_at TIMESTAMP(6) NOT NULL, sha256 TEXT 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,
PRIMARY KEY (authorization_id, package_id), 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, 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 ON DELETE RESTRICT
) ENGINE=InnoDB; );
CREATE TABLE IF NOT EXISTS authorization_hosts ( CREATE TABLE authorization_hosts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
authorization_id BIGINT UNSIGNED NOT NULL, authorization_id INTEGER NOT NULL,
host_fingerprint BINARY(32) NOT NULL, host_fingerprint BLOB NOT NULL,
hostname VARCHAR(255) NOT NULL, hostname TEXT NOT NULL,
first_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_authorization_host (authorization_id, host_fingerprint), UNIQUE (authorization_id, host_fingerprint),
CONSTRAINT fk_authorization_hosts_authorization
FOREIGN KEY (authorization_id) REFERENCES authorizations(id) FOREIGN KEY (authorization_id) REFERENCES authorizations(id)
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB; );
CREATE TABLE IF NOT EXISTS download_sessions ( CREATE TABLE download_sessions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash BINARY(32) NOT NULL UNIQUE, token_hash BLOB NOT NULL UNIQUE,
authorization_id BIGINT UNSIGNED NOT NULL, authorization_id INTEGER NOT NULL,
authorization_host_id BIGINT UNSIGNED NOT NULL, authorization_host_id INTEGER NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP(6) NOT NULL, expires_at DATETIME NOT NULL,
revoked_at TIMESTAMP(6) NULL, revoked_at DATETIME,
INDEX idx_download_sessions_expires (expires_at),
CONSTRAINT fk_download_sessions_authorization
FOREIGN KEY (authorization_id) REFERENCES authorizations(id) FOREIGN KEY (authorization_id) REFERENCES authorizations(id)
ON DELETE CASCADE, 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 ON DELETE CASCADE
) ENGINE=InnoDB; );
CREATE INDEX idx_download_sessions_expires ON download_sessions(expires_at);
CREATE TABLE IF NOT EXISTS audit_events ( CREATE TABLE audit_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(100) NOT NULL, event_type TEXT NOT NULL,
actor VARCHAR(255) NOT NULL DEFAULT '', actor TEXT NOT NULL DEFAULT '',
authorization_id BIGINT UNSIGNED NULL, authorization_id INTEGER,
hostname VARCHAR(255) NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '',
package_slug VARCHAR(100) NOT NULL DEFAULT '', package_slug TEXT NOT NULL DEFAULT '',
source_ip VARCHAR(64) NOT NULL DEFAULT '', source_ip TEXT NOT NULL DEFAULT '',
details TEXT NOT NULL, details TEXT NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
INDEX idx_audit_created (created_at), );
INDEX idx_audit_authorization (authorization_id, created_at), CREATE INDEX idx_audit_created ON audit_events(created_at);
INDEX idx_audit_exchange_limit (event_type, source_ip, created_at) CREATE INDEX idx_audit_authorization
) ENGINE=InnoDB; 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);
-58
View File
@@ -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);
-9
View File
@@ -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);
-7
View File
@@ -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);
-204
View File
@@ -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"