switched to fully self contained docker
This commit is contained in:
@@ -4,3 +4,7 @@
|
|||||||
README.md
|
README.md
|
||||||
docs
|
docs
|
||||||
deploy
|
deploy
|
||||||
|
config
|
||||||
|
main
|
||||||
|
compose*.yaml
|
||||||
|
manage.sh
|
||||||
|
|||||||
+14
-11
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
volumes:
|
||||||
|
- ./deploy/nginx/templates/tls.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||||
+40
-12
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
/usr/local/bin/tapm-migrate
|
||||||
|
exec /usr/local/bin/tapm-server
|
||||||
+92
-166
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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[:],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user