From 943ddb064f5e04f1dd7bf890899b47dc253e6194 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 13:11:37 -0500 Subject: [PATCH] initial upload --- .dockerignore | 6 + .env.example | 15 ++ .gitignore | 5 + Dockerfile | 21 ++ README.md | 35 +++ cmd/migrate/main.go | 94 ++++++++ cmd/server/main.go | 55 +++++ compose.yaml | 29 +++ deploy/nginx/tapm.scity.us.conf | 21 ++ docs/client-api.md | 64 +++++ docs/deployment.md | 153 ++++++++++++ go.mod | 7 + go.sum | 4 + internal/app/auth.go | 280 ++++++++++++++++++++++ internal/app/authorization.go | 366 +++++++++++++++++++++++++++++ internal/app/config.go | 133 +++++++++++ internal/app/download.go | 327 ++++++++++++++++++++++++++ internal/app/security.go | 67 ++++++ internal/app/security_test.go | 30 +++ internal/app/server.go | 260 ++++++++++++++++++++ internal/app/static/app.css | 256 ++++++++++++++++++++ internal/app/static/app.js | 16 ++ internal/app/templates/login.html | 26 ++ internal/app/templates/portal.html | 213 +++++++++++++++++ migrations/001_initial.sql | 103 ++++++++ 25 files changed, 2586 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/migrate/main.go create mode 100644 cmd/server/main.go create mode 100644 compose.yaml create mode 100644 deploy/nginx/tapm.scity.us.conf create mode 100644 docs/client-api.md create mode 100644 docs/deployment.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/auth.go create mode 100644 internal/app/authorization.go create mode 100644 internal/app/config.go create mode 100644 internal/app/download.go create mode 100644 internal/app/security.go create mode 100644 internal/app/security_test.go create mode 100644 internal/app/server.go create mode 100644 internal/app/static/app.css create mode 100644 internal/app/static/app.js create mode 100644 internal/app/templates/login.html create mode 100644 internal/app/templates/portal.html create mode 100644 migrations/001_initial.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bcddbd7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +.DS_Store +README.md +docs +deploy diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0764df0 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +TAPM_LISTEN_ADDR=:8080 +TAPM_PUBLIC_URL=https://tapm.scity.us +TAPM_DATABASE_DSN=tapm:change-me@tcp(galera.example.internal:3306)/tapm_broker?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci&multiStatements=true&time_zone=%27%2B00%3A00%27 +TAPM_GITEA_URL=https://git.scity.us +TAPM_GITEA_CLIENT_ID=replace-me +TAPM_GITEA_CLIENT_SECRET=replace-me +TAPM_GITEA_PACKAGE_OWNER=TAI +TAPM_GITEA_PACKAGE_USERNAME=tapm-packages +TAPM_GITEA_PACKAGE_TOKEN=replace-me +TAPM_ALLOWED_GITEA_USERS=taiadmin +TAPM_COOKIE_SECRET=replace-with-at-least-32-random-bytes +TAPM_DEFAULT_DURATION=3h +TAPM_DEFAULT_HOST_LIMIT=3 +TAPM_MAX_HOST_LIMIT=25 +TAPM_TRUST_PROXY_HEADERS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..328b3e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.DS_Store +/bin/ +/dist/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..acb56c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.24-alpine AS build + +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tapm-server ./cmd/server \ + && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tapm-migrate ./cmd/migrate + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates \ + && addgroup -S tapm \ + && adduser -S -G tapm -H tapm +WORKDIR /app +COPY --from=build /out/tapm-server /usr/local/bin/tapm-server +COPY --from=build /out/tapm-migrate /usr/local/bin/tapm-migrate +COPY migrations /app/migrations +USER tapm +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/tapm-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..64b490f --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# TAPM Deployment Access + +TAPM Deployment Access is a short-lived authorization broker for protected +deployment packages. Technicians authenticate with Gitea, create a deployment +code, and permit a limited number of hosts to download explicitly selected +packages during a fixed authorization window. + +Default policy: + +- Public URL: `https://tapm.scity.us` +- Authorization lifetime: 3 hours +- Host limit: 3 +- Authentication: Gitea OAuth +- Shared state: MariaDB Galera +- Package storage: private Gitea Generic Package Registry + +See [docs/deployment.md](docs/deployment.md) for installation and configuration. +The ProxMenu integration contract is documented in +[docs/client-api.md](docs/client-api.md). + +## Components + +- `cmd/server`: portal, OAuth flow, authorization API, and protected downloads +- `cmd/migrate`: ordered MariaDB schema migrations with an advisory lock +- `internal/app`: application, security, and registry proxy logic +- `deploy/nginx`: TLS reverse-proxy example +- `compose.yaml`: hardened, loopback-only container deployment + +## Local checks + +```sh +docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine \ + sh -c 'gofmt -w cmd internal && go test ./...' +docker build -t tai/tapm-deployment-broker:local . +``` diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..4f87b7d --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := os.Getenv("TAPM_DATABASE_DSN") + if dsn == "" { + log.Fatal("TAPM_DATABASE_DSN is required") + } + directory := os.Getenv("TAPM_MIGRATIONS_DIR") + if directory == "" { + directory = "/app/migrations" + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + log.Fatalf("database: %v", err) + } + + var locked int + if err := db.QueryRowContext(ctx, `SELECT GET_LOCK('tapm_schema_migrations', 30)`).Scan(&locked); err != nil { + log.Fatal(err) + } + if locked != 1 { + log.Fatal("could not acquire migration lock") + } + defer db.ExecContext(context.Background(), `SELECT RELEASE_LOCK('tapm_schema_migrations')`) + + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT UNSIGNED NOT NULL PRIMARY KEY, + applied_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + ) ENGINE=InnoDB`); err != nil { + log.Fatal(err) + } + + entries, err := os.ReadDir(directory) + if err != nil { + log.Fatal(err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + versionText, _, ok := strings.Cut(entry.Name(), "_") + if !ok { + log.Fatalf("migration %q must begin with a numeric version and underscore", entry.Name()) + } + version, err := strconv.ParseUint(versionText, 10, 64) + if err != nil { + log.Fatalf("migration %q: %v", entry.Name(), err) + } + var applied int + if err := db.QueryRowContext( + ctx, + `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, + version, + ).Scan(&applied); err != nil { + log.Fatal(err) + } + if applied != 0 { + continue + } + + body, err := os.ReadFile(filepath.Join(directory, entry.Name())) + if err != nil { + log.Fatal(err) + } + if _, err := db.ExecContext(ctx, string(body)); err != nil { + log.Fatalf("apply %s: %v", entry.Name(), err) + } + fmt.Printf("applied migration %s\n", entry.Name()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1acd7dc --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.scity.us/TAI/TA-Deployment-Broker/internal/app" +) + +func main() { + cfg, err := app.LoadConfig() + if err != nil { + log.Fatalf("configuration: %v", err) + } + + server, err := app.New(cfg) + if err != nil { + log.Fatalf("initialize: %v", err) + } + defer server.Close() + + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: server.Routes(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 15 * time.Minute, + IdleTimeout: 90 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + go func() { + log.Printf("TAPM Deployment Access listening on %s", cfg.ListenAddr) + if err := httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("serve: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..0228c83 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,29 @@ +services: + migrate: + build: . + image: tai/tapm-deployment-broker:local + entrypoint: ["/usr/local/bin/tapm-migrate"] + env_file: .env + restart: "no" + + broker: + build: . + image: tai/tapm-deployment-broker:local + env_file: .env + restart: unless-stopped + init: true + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + tmpfs: + - /tmp:size=16m,mode=1777 + ports: + - "127.0.0.1:8080:8080" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/health/ready"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/deploy/nginx/tapm.scity.us.conf b/deploy/nginx/tapm.scity.us.conf new file mode 100644 index 0000000..d262281 --- /dev/null +++ b/deploy/nginx/tapm.scity.us.conf @@ -0,0 +1,21 @@ +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 64k; + + 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_read_timeout 5m; + proxy_request_buffering off; + proxy_buffering off; + } +} diff --git a/docs/client-api.md b/docs/client-api.md new file mode 100644 index 0000000..19482f4 --- /dev/null +++ b/docs/client-api.md @@ -0,0 +1,64 @@ +# Proxmox client API + +The ProxMenu client uses two HTTPS requests. It never receives Gitea +credentials. + +## Exchange a deployment code + +`POST https://tapm.scity.us/api/v1/exchange` + +```json +{ + "code": "TAPM-ABCDE-FGHIJ", + "host_fingerprint": "sha256-of-the-host-machine-id", + "hostname": "pve01" +} +``` + +Successful response: + +```json +{ + "session_token": "opaque-session-token", + "expires_at": "2026-07-25T23:30:00Z", + "packages": [ + { + "slug": "sentinelone-linux", + "display_name": "SentinelOne Linux Agent", + "version": "26.1.1.31", + "sha256": "64-lowercase-hexadecimal-characters", + "download_url": "https://tapm.scity.us/api/v1/packages/sentinelone-linux" + } + ] +} +``` + +The first exchange enrolls that host against the authorization. Reusing the +same code and host fingerprint does not consume another host slot. A different +fingerprint consumes one slot until the configured limit is reached. + +## Download a package + +Send the session from the exchange: + +```sh +curl --fail --location \ + --header "Authorization: Bearer SESSION_TOKEN" \ + --output /tmp/package.deb \ + 'https://tapm.scity.us/api/v1/packages/sentinelone-linux' +``` + +The client must calculate SHA-256 after download and compare it to the value +returned by the exchange. It must delete the file and stop if the values differ. +The server also returns the expected value in `X-TAPM-SHA256`. + +## Status behavior + +- `400`: malformed request +- `403`: invalid, expired, revoked, or exhausted authorization +- `429`: too many failed exchanges from the source address; retry after the + `Retry-After` interval +- `502`: private package registry unavailable + +Codes and sessions expire at the same authorization deadline. Revoking an +authorization immediately invalidates all of its sessions. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..ca2bd21 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,153 @@ +# Deployment + +This service is intended to run on both webserver VMs, with only the active +webserver's broker container started. Both installations use the same MariaDB +Galera database, Gitea application, package registry, and secrets. + +## 1. DNS and TLS + +Create `tapm.scity.us` in PowerDNS and point it at the existing HAProxy +frontend. Install the certificate using the existing certificate automation. +The broker only listens on `127.0.0.1:8080`; Nginx terminates TLS. + +An example virtual host is in +[`deploy/nginx/tapm.scity.us.conf`](../deploy/nginx/tapm.scity.us.conf). +Adjust only the certificate paths if the local convention differs. + +## 2. MariaDB Galera + +Create a dedicated database and least-privilege application user. Run this once +against the cluster as a database administrator: + +```sql +CREATE DATABASE tapm_broker + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; +CREATE USER 'tapm'@'10.%' IDENTIFIED BY 'replace-with-a-random-password'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES + ON tapm_broker.* TO 'tapm'@'10.%'; +FLUSH PRIVILEGES; +``` + +Restrict the host pattern further if the two webserver addresses are known. +The application does not need global privileges. + +## 3. Gitea accounts and OAuth + +Create these separately: + +1. The `taiadmin` technician account used to sign in to the portal. +2. A `tapm-packages` service account with read access only to the private + `TAI/files` package owner. Create a personal access token for this account + and store it only in the broker environment file. +3. An OAuth2 application with callback URL + `https://tapm.scity.us/auth/callback`. + +The OAuth client secret authenticates the portal to Gitea. The package token +is used only server-side while proxying an authorized file. Neither value is +returned to a technician or Proxmox host. + +## 4. Broker configuration + +On each webserver: + +```sh +git clone https://git.scity.us/TAI/TA-Deployment-Broker.git +cd TA-Deployment-Broker +cp .env.example .env +chmod 600 .env +``` + +Edit `.env` and use the same values on both webservers. Generate the cookie +secret with: + +```sh +openssl rand -base64 48 +``` + +The database DSN must retain `parseTime=true`, `multiStatements=true`, and the +encoded UTC `time_zone` setting from `.env.example`. +Set the Galera address to an existing database VIP or load-balanced endpoint, +not a single cluster member. + +## 5. Build and migrate + +Build the image on each webserver: + +```sh +docker compose build +``` + +Apply migrations from only one webserver: + +```sh +docker compose run --rm migrate +``` + +Migrations use a MariaDB advisory lock, so an accidental simultaneous run +cannot apply the same migration twice. + +## 6. Start the active instance + +On the active webserver: + +```sh +docker compose up -d broker +curl --fail http://127.0.0.1:8080/health/ready +``` + +On the standby webserver, keep the image and `.env` current but leave the +broker stopped. The existing failover automation can run: + +```sh +docker compose stop broker +``` + +on the old active VM, followed by: + +```sh +docker compose up -d broker +``` + +on the new active VM. There is no local application state to transfer. + +## 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. + +Only the active webserver should be eligible for traffic. Retaining a single +active origin avoids duplicate service management while Galera preserves +sessions and authorization state across failover. + +## 8. Publish a protected package + +Publish files to the private Gitea Generic Package Registry: + +```sh +curl --user 'publisher:PACKAGE_WRITE_TOKEN' \ + --upload-file ./SentinelAgent_linux_x86_64.deb \ + 'https://git.scity.us/api/packages/TAI/generic/sentinelone-linux/26.1.1.31/SentinelAgent_linux_x86_64.deb' +``` + +Calculate its checksum: + +```sh +sha256sum SentinelAgent_linux_x86_64.deb +``` + +Sign in at `https://tapm.scity.us`, enter the matching registry package name, +version, filename, and SHA-256, then enable it. The broker intentionally does +not browse the registry with the technician's credentials. + +## 9. Backup and rotation + +- Include `tapm_broker` in the existing Galera backup policy. +- Keep `.env` outside Git and readable only by the service administrator. +- Rotate the Gitea package token and 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..53a1600 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.scity.us/TAI/TA-Deployment-Broker + +go 1.24 + +require github.com/go-sql-driver/mysql v1.9.3 + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bcdcfa --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= diff --git a/internal/app/auth.go b/internal/app/auth.go new file mode 100644 index 0000000..6ef9e27 --- /dev/null +++ b/internal/app/auth.go @@ -0,0 +1,280 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + sessionCookieName = "tapm_session" + oauthCookieName = "tapm_oauth_state" +) + +type giteaTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` +} + +type giteaUser struct { + Login string `json:"login"` + FullName string `json:"full_name"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + state, err := randomToken(24) + if err != nil { + http.Error(w, "unable to start sign-in", http.StatusInternalServerError) + return + } + signedState := state + "." + signValue(s.cfg.CookieSecret, state) + http.SetCookie(w, &http.Cookie{ + Name: oauthCookieName, + Value: signedState, + Path: "/auth", + MaxAge: 600, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback" + query := url.Values{ + "client_id": {s.cfg.GiteaClientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "state": {state}, + } + http.Redirect( + w, + r, + joinURL(s.cfg.GiteaURL, "/login/oauth/authorize")+"?"+query.Encode(), + http.StatusFound, + ) +} + +func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(oauthCookieName) + if err != nil { + http.Error(w, "sign-in state is missing", http.StatusBadRequest) + return + } + parts := strings.Split(cookie.Value, ".") + if len(parts) != 2 || + !verifySignature(s.cfg.CookieSecret, parts[0], parts[1]) || + r.URL.Query().Get("state") != parts[0] { + http.Error(w, "sign-in state is invalid", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{ + Name: oauthCookieName, + Value: "", + Path: "/auth", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "authorization code is missing", http.StatusBadRequest) + return + } + + user, err := s.exchangeOAuthCode(r.Context(), code) + if err != nil { + http.Error(w, "Gitea sign-in failed", http.StatusBadGateway) + return + } + if _, allowed := s.cfg.AllowedGiteaUsers[strings.ToLower(user.Login)]; !allowed { + _ = s.audit(r.Context(), "login_denied", user.Login, nil, "", "", s.clientIP(r), "user is not allowed") + http.Error(w, "this Gitea user is not authorized", http.StatusForbidden) + return + } + + sessionToken, err := randomToken(32) + if err != nil { + http.Error(w, "unable to create session", http.StatusInternalServerError) + return + } + csrfToken, err := randomToken(24) + if err != nil { + http.Error(w, "unable to create session", http.StatusInternalServerError) + return + } + tokenHash := hashValue(sessionToken) + displayName := strings.TrimSpace(user.FullName) + if displayName == "" { + displayName = user.Login + } + expiresAt := time.Now().UTC().Add(12 * time.Hour) + _, err = s.db.ExecContext( + r.Context(), + `INSERT INTO technician_sessions + (token_hash, csrf_token, gitea_login, display_name, expires_at) + VALUES (?, ?, ?, ?, ?)`, + tokenHash[:], csrfToken, user.Login, displayName, expiresAt, + ) + if err != nil { + http.Error(w, "unable to save session", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sessionToken, + Path: "/", + Expires: expiresAt, + MaxAge: int(time.Until(expiresAt).Seconds()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + _ = s.audit(r.Context(), "login_succeeded", user.Login, nil, "", "", s.clientIP(r), "") + http.Redirect(w, r, "/portal", http.StatusSeeOther) +} + +func (s *Server) exchangeOAuthCode(ctx context.Context, code string) (giteaUser, error) { + var user giteaUser + redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback" + form := url.Values{ + "client_id": {s.cfg.GiteaClientID}, + "client_secret": {s.cfg.GiteaClientSecret}, + "code": {code}, + "grant_type": {"authorization_code"}, + "redirect_uri": {redirectURI}, + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + joinURL(s.cfg.GiteaURL, "/login/oauth/access_token"), + strings.NewReader(form.Encode()), + ) + if err != nil { + return user, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := s.client.Do(request) + if err != nil { + return user, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("token endpoint returned %s", response.Status) + } + var token giteaTokenResponse + if err := json.NewDecoder(response.Body).Decode(&token); err != nil { + return user, err + } + if token.AccessToken == "" { + return user, fmt.Errorf("token endpoint returned an empty token") + } + + request, err = http.NewRequestWithContext( + ctx, + http.MethodGet, + joinURL(s.cfg.GiteaURL, "/api/v1/user"), + nil, + ) + if err != nil { + return user, err + } + request.Header.Set("Authorization", "token "+token.AccessToken) + response, err = s.client.Do(request) + if err != nil { + return user, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("user endpoint returned %s", response.Status) + } + if err := json.NewDecoder(response.Body).Decode(&user); err != nil { + return user, err + } + if user.Login == "" { + return user, fmt.Errorf("user endpoint returned an empty login") + } + return user, nil +} + +func (s *Server) currentTechnician(r *http.Request) (*technician, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, err + } + tokenHash := hashValue(cookie.Value) + var tech technician + err = s.db.QueryRowContext( + r.Context(), + `SELECT gitea_login, display_name, csrf_token + FROM technician_sessions + WHERE token_hash = ? AND expires_at > UTC_TIMESTAMP(6)`, + tokenHash[:], + ).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken) + if err != nil { + return nil, err + } + _, _ = s.db.ExecContext( + r.Context(), + `UPDATE technician_sessions SET last_seen_at = UTC_TIMESTAMP(6) + WHERE token_hash = ?`, + tokenHash[:], + ) + return &tech, nil +} + +func (s *Server) requireTechnician(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + if !errorsIsNoRowsOrCookie(err) { + http.Error(w, "unable to validate session", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if r.Method != http.MethodGet && r.FormValue("csrf_token") != tech.CSRFToken { + http.Error(w, "invalid request token", http.StatusForbidden) + return + } + next(w, r) + } +} + +func errorsIsNoRowsOrCookie(err error) bool { + return err == http.ErrNoCookie || err == sql.ErrNoRows +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if r.FormValue("csrf_token") != tech.CSRFToken { + http.Error(w, "invalid request token", http.StatusForbidden) + return + } + if cookie, err := r.Cookie(sessionCookieName); err == nil { + tokenHash := hashValue(cookie.Value) + _, _ = s.db.ExecContext(r.Context(), `DELETE FROM technician_sessions WHERE token_hash = ?`, tokenHash[:]) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/app/authorization.go b/internal/app/authorization.go new file mode 100644 index 0000000..2bd6f3c --- /dev/null +++ b/internal/app/authorization.go @@ -0,0 +1,366 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + packages, err := s.listPackages(r) + if err != nil { + http.Error(w, "unable to list packages", http.StatusInternalServerError) + return + } + authorizations, err := s.listAuthorizations(r) + if err != nil { + http.Error(w, "unable to list authorizations", http.StatusInternalServerError) + return + } + auditEvents, err := s.listAuditEvents(r) + if err != nil { + http.Error(w, "unable to list audit events", http.StatusInternalServerError) + return + } + s.render(w, "portal.html", pageData{ + Title: "Deployment Access", + Technician: tech, + CSRFToken: tech.CSRFToken, + Authorizations: authorizations, + AuditEvents: auditEvents, + Packages: packages, + NewCode: r.URL.Query().Get("code"), + DefaultHostLimit: s.cfg.DefaultHostLimit, + DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())), + Notice: r.URL.Query().Get("notice"), + }) +} + +func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) { + rows, err := s.db.QueryContext( + r.Context(), + `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at + FROM audit_events + ORDER BY created_at DESC + LIMIT 100`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []auditRecord + for rows.Next() { + var record auditRecord + if err := rows.Scan( + &record.EventType, + &record.Actor, + &record.Hostname, + &record.PackageSlug, + &record.SourceIP, + &record.Details, + &record.CreatedAt, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (s *Server) listPackages(r *http.Request) ([]packageRecord, error) { + rows, err := s.db.QueryContext( + r.Context(), + `SELECT id, slug, display_name, package_name, package_version, + file_name, sha256, enabled + FROM packages + ORDER BY display_name, package_version`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var records []packageRecord + for rows.Next() { + var record packageRecord + if err := rows.Scan( + &record.ID, + &record.Slug, + &record.DisplayName, + &record.PackageName, + &record.PackageVersion, + &record.FileName, + &record.SHA256, + &record.Enabled, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, error) { + rows, err := s.db.QueryContext( + r.Context(), + `SELECT a.id, a.code_hint, a.created_by, a.customer_label, + a.host_limit, COUNT(DISTINCT h.id), a.created_at, + a.expires_at, a.revoked_at, + COALESCE(GROUP_CONCAT(DISTINCT p.display_name + ORDER BY p.display_name SEPARATOR ', '), '') + FROM authorizations a + LEFT JOIN authorization_hosts h ON h.authorization_id = a.id + LEFT JOIN authorization_packages ap ON ap.authorization_id = a.id + LEFT JOIN packages p ON p.id = ap.package_id + WHERE a.created_at > UTC_TIMESTAMP(6) - INTERVAL 30 DAY + GROUP BY a.id + ORDER BY a.created_at DESC + LIMIT 100`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var records []authorizationRecord + for rows.Next() { + var record authorizationRecord + if err := rows.Scan( + &record.ID, + &record.CodeHint, + &record.CreatedBy, + &record.CustomerLabel, + &record.HostLimit, + &record.HostCount, + &record.CreatedAt, + &record.ExpiresAt, + &record.RevokedAt, + &record.Packages, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) { + tech, _ := s.currentTechnician(r) + hostLimit, err := strconv.Atoi(r.FormValue("host_limit")) + if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit { + http.Error(w, "invalid host limit", http.StatusBadRequest) + return + } + durationHours, err := strconv.Atoi(r.FormValue("duration_hours")) + if err != nil || durationHours < 1 || durationHours > 24 { + http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest) + return + } + packageIDs := r.Form["package_id"] + if len(packageIDs) == 0 { + http.Error(w, "select at least one package", http.StatusBadRequest) + return + } + code, err := deploymentCode() + if err != nil { + http.Error(w, "unable to generate code", http.StatusInternalServerError) + return + } + codeHash := hashValue(code) + expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour) + customerLabel := strings.TrimSpace(r.FormValue("customer_label")) + if len(customerLabel) > 255 { + http.Error(w, "customer label is too long", http.StatusBadRequest) + return + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, "unable to create authorization", http.StatusInternalServerError) + return + } + defer tx.Rollback() + result, err := tx.ExecContext( + r.Context(), + `INSERT INTO authorizations + (code_hash, code_hint, created_by, customer_label, host_limit, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + codeHash[:], code[len(code)-5:], tech.Login, customerLabel, hostLimit, expiresAt, + ) + if err != nil { + http.Error(w, "unable to create authorization", http.StatusInternalServerError) + return + } + authorizationID, _ := result.LastInsertId() + for _, rawID := range packageIDs { + packageID, err := strconv.ParseUint(rawID, 10, 64) + if err != nil { + http.Error(w, "invalid package selection", http.StatusBadRequest) + return + } + result, err := tx.ExecContext( + r.Context(), + `INSERT INTO authorization_packages (authorization_id, package_id) + SELECT ?, id FROM packages WHERE id = ? AND enabled = TRUE`, + authorizationID, packageID, + ) + if err != nil { + http.Error(w, "unable to authorize package", http.StatusInternalServerError) + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + http.Error(w, "selected package is unavailable", http.StatusBadRequest) + return + } + } + if err := tx.Commit(); err != nil { + http.Error(w, "unable to save authorization", http.StatusInternalServerError) + return + } + id := uint64(authorizationID) + _ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r), + fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel)) + http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther) +} + +func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) { + tech, _ := s.currentTechnician(r) + id, err := parseUintPath(r, "id") + if err != nil { + http.Error(w, "invalid authorization", http.StatusBadRequest) + return + } + result, err := s.db.ExecContext( + r.Context(), + `UPDATE authorizations + SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(6)) + WHERE id = ?`, + id, + ) + if err != nil { + http.Error(w, "unable to revoke authorization", http.StatusInternalServerError) + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + http.Error(w, "authorization not found", http.StatusNotFound) + return + } + _, _ = s.db.ExecContext( + r.Context(), + `UPDATE download_sessions SET revoked_at = UTC_TIMESTAMP(6) + WHERE authorization_id = ? AND revoked_at IS NULL`, + id, + ) + _ = s.audit(r.Context(), "authorization_revoked", tech.Login, &id, "", "", s.clientIP(r), "") + http.Redirect(w, r, "/portal?notice=Authorization+revoked", http.StatusSeeOther) +} + +func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) { + tech, _ := s.currentTechnician(r) + fields := []string{ + "slug", "display_name", "package_name", "package_version", "file_name", "sha256", + } + values := make(map[string]string) + for _, field := range fields { + values[field] = strings.TrimSpace(r.FormValue(field)) + if values[field] == "" { + http.Error(w, field+" is required", http.StatusBadRequest) + return + } + } + if !validSlug(values["slug"]) || !validSHA256(values["sha256"]) { + http.Error(w, "invalid slug or SHA-256", http.StatusBadRequest) + return + } + enabled := r.FormValue("enabled") == "1" + _, err := s.db.ExecContext( + r.Context(), + `INSERT INTO packages + (slug, display_name, package_name, package_version, file_name, sha256, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + package_name = VALUES(package_name), + package_version = VALUES(package_version), + file_name = VALUES(file_name), + sha256 = VALUES(sha256), + enabled = VALUES(enabled)`, + values["slug"], + values["display_name"], + values["package_name"], + values["package_version"], + values["file_name"], + strings.ToLower(values["sha256"]), + enabled, + ) + if err != nil { + http.Error(w, "unable to save package", http.StatusInternalServerError) + return + } + _ = s.audit(r.Context(), "package_saved", tech.Login, nil, "", values["slug"], s.clientIP(r), + fmt.Sprintf("version=%s enabled=%t", values["package_version"], enabled)) + http.Redirect(w, r, "/portal?notice=Package+saved", http.StatusSeeOther) +} + +func validSlug(value string) bool { + if value == "" || len(value) > 100 { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && + character != '-' && character != '_' { + return false + } + } + return true +} + +func validSHA256(value string) bool { + if len(value) != 64 { + return false + } + for _, character := range strings.ToLower(value) { + if (character < '0' || character > '9') && + (character < 'a' || character > 'f') { + return false + } + } + return true +} + +func (s *Server) audit( + ctx context.Context, + eventType string, + actor string, + authorizationID *uint64, + hostname string, + packageSlug string, + sourceIP string, + details string, +) error { + var authID any + if authorizationID != nil { + authID = *authorizationID + } + _, err := s.db.ExecContext( + ctx, + `INSERT INTO audit_events + (event_type, actor, authorization_id, hostname, package_slug, source_ip, details) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + eventType, actor, authID, hostname, packageSlug, sourceIP, details, + ) + return err +} + +var errAuthorizationDenied = errors.New("authorization denied") diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..a54fabf --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,133 @@ +package app + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + ListenAddr string + PublicURL *url.URL + DatabaseDSN string + GiteaURL *url.URL + GiteaClientID string + GiteaClientSecret string + GiteaPackageOwner string + GiteaPackageUser string + GiteaPackageToken string + AllowedGiteaUsers map[string]struct{} + CookieSecret []byte + DefaultDuration time.Duration + DefaultHostLimit int + MaxHostLimit int + TrustProxyHeaders bool +} + +func LoadConfig() (Config, error) { + var cfg Config + var err error + + cfg.ListenAddr = envDefault("TAPM_LISTEN_ADDR", ":8080") + cfg.DatabaseDSN = os.Getenv("TAPM_DATABASE_DSN") + cfg.GiteaClientID = os.Getenv("TAPM_GITEA_CLIENT_ID") + cfg.GiteaClientSecret = os.Getenv("TAPM_GITEA_CLIENT_SECRET") + cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI") + cfg.GiteaPackageUser = os.Getenv("TAPM_GITEA_PACKAGE_USERNAME") + cfg.GiteaPackageToken = os.Getenv("TAPM_GITEA_PACKAGE_TOKEN") + cfg.CookieSecret = []byte(os.Getenv("TAPM_COOKIE_SECRET")) + + cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL") + if err != nil { + return cfg, err + } + cfg.GiteaURL, err = parseAbsoluteURL("TAPM_GITEA_URL") + if err != nil { + return cfg, err + } + + cfg.DefaultDuration, err = time.ParseDuration( + envDefault("TAPM_DEFAULT_DURATION", "3h"), + ) + if err != nil || cfg.DefaultDuration <= 0 { + return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive") + } + cfg.DefaultHostLimit, err = envInt("TAPM_DEFAULT_HOST_LIMIT", 3) + if err != nil { + return cfg, err + } + cfg.MaxHostLimit, err = envInt("TAPM_MAX_HOST_LIMIT", 25) + if err != nil { + return cfg, err + } + if cfg.DefaultHostLimit < 1 || cfg.MaxHostLimit < cfg.DefaultHostLimit { + return cfg, fmt.Errorf("invalid default or maximum host limit") + } + + cfg.TrustProxyHeaders, err = strconv.ParseBool( + envDefault("TAPM_TRUST_PROXY_HEADERS", "false"), + ) + if err != nil { + return cfg, fmt.Errorf("TAPM_TRUST_PROXY_HEADERS: %w", err) + } + + cfg.AllowedGiteaUsers = make(map[string]struct{}) + for _, user := range strings.Split( + envDefault("TAPM_ALLOWED_GITEA_USERS", "taiadmin"), + ",", + ) { + user = strings.ToLower(strings.TrimSpace(user)) + if user != "" { + cfg.AllowedGiteaUsers[user] = struct{}{} + } + } + + required := map[string]string{ + "TAPM_DATABASE_DSN": cfg.DatabaseDSN, + "TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID, + "TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret, + "TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser, + "TAPM_GITEA_PACKAGE_TOKEN": cfg.GiteaPackageToken, + } + for name, value := range required { + if strings.TrimSpace(value) == "" { + return cfg, fmt.Errorf("%s is required", name) + } + } + if len(cfg.CookieSecret) < 32 { + return cfg, fmt.Errorf("TAPM_COOKIE_SECRET must contain at least 32 bytes") + } + if len(cfg.AllowedGiteaUsers) == 0 { + return cfg, fmt.Errorf("at least one Gitea user must be allowed") + } + + return cfg, nil +} + +func parseAbsoluteURL(name string) (*url.URL, error) { + raw := os.Getenv(name) + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return nil, fmt.Errorf("%s must be an absolute HTTPS URL", name) + } + return parsed, nil +} + +func envDefault(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func envInt(name string, fallback int) (int, error) { + raw := envDefault(name, strconv.Itoa(fallback)) + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("%s must be an integer", name) + } + return value, nil +} diff --git a/internal/app/download.go b/internal/app/download.go new file mode 100644 index 0000000..dd2bd2b --- /dev/null +++ b/internal/app/download.go @@ -0,0 +1,327 @@ +package app + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type exchangeRequest struct { + Code string `json:"code"` + HostFingerprint string `json:"host_fingerprint"` + Hostname string `json:"hostname"` +} + +type exchangePackage struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + DownloadURL string `json:"download_url"` +} + +type exchangeResponse struct { + SessionToken string `json:"session_token"` + ExpiresAt time.Time `json:"expires_at"` + Packages []exchangePackage `json:"packages"` +} + +func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) { + sourceIP := s.clientIP(r) + limited, err := s.exchangeRateLimited(r, sourceIP) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to validate request"}) + return + } + if limited { + w.Header().Set("Retry-After", "600") + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many failed attempts; try again later"}) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) + var request exchangeRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + request.Code = normalizeCode(request.Code) + request.HostFingerprint = strings.TrimSpace(request.HostFingerprint) + request.Hostname = strings.TrimSpace(request.Hostname) + if request.Code == "" || len(request.HostFingerprint) < 16 || + request.Hostname == "" || len(request.Hostname) > 255 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code and host identity are required"}) + return + } + + response, authorizationID, err := s.exchangeDeploymentCode(r, request) + if err != nil { + status := http.StatusInternalServerError + message := "unable to create download session" + if errors.Is(err, errAuthorizationDenied) { + status = http.StatusForbidden + message = "authorization is invalid, expired, revoked, or at its host limit" + } + _ = s.audit(r.Context(), "code_exchange_failed", "", nil, request.Hostname, "", sourceIP, err.Error()) + writeJSON(w, status, map[string]string{"error": message}) + return + } + _ = s.audit(r.Context(), "code_exchanged", "", &authorizationID, request.Hostname, "", sourceIP, "") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) exchangeRateLimited(r *http.Request, sourceIP string) (bool, error) { + var attempts int + err := s.db.QueryRowContext( + r.Context(), + `SELECT COUNT(*) + FROM audit_events + WHERE event_type = 'code_exchange_failed' + AND source_ip = ? + AND created_at > UTC_TIMESTAMP(6) - INTERVAL 10 MINUTE`, + sourceIP, + ).Scan(&attempts) + return attempts >= 10, err +} + +func (s *Server) exchangeDeploymentCode( + r *http.Request, + request exchangeRequest, +) (exchangeResponse, uint64, error) { + var response exchangeResponse + codeHash := hashValue(request.Code) + fingerprintHash := hashValue(request.HostFingerprint) + + tx, err := s.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return response, 0, err + } + defer tx.Rollback() + + var authorizationID uint64 + var hostLimit int + var expiresAt time.Time + err = tx.QueryRowContext( + r.Context(), + `SELECT id, host_limit, expires_at + FROM authorizations + WHERE code_hash = ? + AND revoked_at IS NULL + AND expires_at > UTC_TIMESTAMP(6) + FOR UPDATE`, + codeHash[:], + ).Scan(&authorizationID, &hostLimit, &expiresAt) + if errors.Is(err, sql.ErrNoRows) { + return response, 0, errAuthorizationDenied + } + if err != nil { + return response, 0, err + } + + var hostID uint64 + err = tx.QueryRowContext( + r.Context(), + `SELECT id FROM authorization_hosts + WHERE authorization_id = ? AND host_fingerprint = ?`, + authorizationID, fingerprintHash[:], + ).Scan(&hostID) + if errors.Is(err, sql.ErrNoRows) { + var hostCount int + if err := tx.QueryRowContext( + r.Context(), + `SELECT COUNT(*) FROM authorization_hosts WHERE authorization_id = ?`, + authorizationID, + ).Scan(&hostCount); err != nil { + return response, 0, err + } + if hostCount >= hostLimit { + return response, 0, errAuthorizationDenied + } + result, err := tx.ExecContext( + r.Context(), + `INSERT INTO authorization_hosts + (authorization_id, host_fingerprint, hostname) + VALUES (?, ?, ?)`, + authorizationID, fingerprintHash[:], request.Hostname, + ) + if err != nil { + return response, 0, err + } + insertedID, _ := result.LastInsertId() + hostID = uint64(insertedID) + } else if err != nil { + return response, 0, err + } else { + _, err = tx.ExecContext( + r.Context(), + `UPDATE authorization_hosts + SET hostname = ?, last_seen_at = UTC_TIMESTAMP(6) + WHERE id = ?`, + request.Hostname, hostID, + ) + if err != nil { + return response, 0, err + } + } + + sessionToken, err := randomToken(32) + if err != nil { + return response, 0, err + } + sessionHash := hashValue(sessionToken) + _, err = tx.ExecContext( + r.Context(), + `INSERT INTO download_sessions + (token_hash, authorization_id, authorization_host_id, expires_at) + VALUES (?, ?, ?, ?)`, + sessionHash[:], authorizationID, hostID, expiresAt, + ) + if err != nil { + return response, 0, err + } + + rows, err := tx.QueryContext( + r.Context(), + `SELECT p.slug, p.display_name, p.package_version, p.sha256 + FROM authorization_packages ap + JOIN packages p ON p.id = ap.package_id + WHERE ap.authorization_id = ? AND p.enabled = TRUE + ORDER BY p.display_name`, + authorizationID, + ) + if err != nil { + return response, 0, err + } + defer rows.Close() + for rows.Next() { + var record exchangePackage + if err := rows.Scan( + &record.Slug, + &record.DisplayName, + &record.Version, + &record.SHA256, + ); err != nil { + return response, 0, err + } + record.DownloadURL = strings.TrimRight(s.cfg.PublicURL.String(), "/") + + "/api/v1/packages/" + url.PathEscape(record.Slug) + response.Packages = append(response.Packages, record) + } + if err := rows.Err(); err != nil { + return response, 0, err + } + if len(response.Packages) == 0 { + return response, 0, errAuthorizationDenied + } + + if err := tx.Commit(); err != nil { + return response, 0, err + } + response.SessionToken = sessionToken + response.ExpiresAt = expiresAt + return response, authorizationID, nil +} + +func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) { + const bearerPrefix = "Bearer " + authorization := r.Header.Get("Authorization") + if !strings.HasPrefix(authorization, bearerPrefix) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "bearer token required"}) + return + } + sessionToken := strings.TrimSpace(strings.TrimPrefix(authorization, bearerPrefix)) + if sessionToken == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "bearer token required"}) + return + } + slug := r.PathValue("slug") + sessionHash := hashValue(sessionToken) + + var packageInfo packageRecord + var authorizationID uint64 + var hostname string + err := s.db.QueryRowContext( + r.Context(), + `SELECT p.id, p.slug, p.display_name, p.package_name, + p.package_version, p.file_name, p.sha256, p.enabled, + ds.authorization_id, ah.hostname + FROM download_sessions ds + JOIN authorizations a ON a.id = ds.authorization_id + JOIN authorization_hosts ah ON ah.id = ds.authorization_host_id + JOIN authorization_packages ap ON ap.authorization_id = a.id + JOIN packages p ON p.id = ap.package_id + WHERE ds.token_hash = ? + AND ds.revoked_at IS NULL + AND ds.expires_at > UTC_TIMESTAMP(6) + AND a.revoked_at IS NULL + AND a.expires_at > UTC_TIMESTAMP(6) + AND p.slug = ? + AND p.enabled = TRUE`, + sessionHash[:], slug, + ).Scan( + &packageInfo.ID, + &packageInfo.Slug, + &packageInfo.DisplayName, + &packageInfo.PackageName, + &packageInfo.PackageVersion, + &packageInfo.FileName, + &packageInfo.SHA256, + &packageInfo.Enabled, + &authorizationID, + &hostname, + ) + if errors.Is(err, sql.ErrNoRows) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "package is not authorized"}) + return + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to authorize package"}) + return + } + + registryURL := joinURL( + s.cfg.GiteaURL, + fmt.Sprintf( + "/api/packages/%s/generic/%s/%s/%s", + url.PathEscape(s.cfg.GiteaPackageOwner), + url.PathEscape(packageInfo.PackageName), + url.PathEscape(packageInfo.PackageVersion), + url.PathEscape(packageInfo.FileName), + ), + ) + upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodGet, registryURL, nil) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to request package"}) + return + } + upstreamRequest.SetBasicAuth(s.cfg.GiteaPackageUser, s.cfg.GiteaPackageToken) + upstreamResponse, err := s.packageClient.Do(upstreamRequest) + if err != nil { + _ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), err.Error()) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry is unavailable"}) + return + } + defer upstreamResponse.Body.Close() + if upstreamResponse.StatusCode != http.StatusOK { + _ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), upstreamResponse.Status) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry rejected the request"}) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", packageInfo.FileName)) + w.Header().Set("X-TAPM-SHA256", packageInfo.SHA256) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, upstreamResponse.Body); err != nil { + _ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), err.Error()) + return + } + _ = s.audit(r.Context(), "package_downloaded", "", &authorizationID, hostname, slug, s.clientIP(r), "") +} diff --git a/internal/app/security.go b/internal/app/security.go new file mode 100644 index 0000000..ad5df68 --- /dev/null +++ b/internal/app/security.go @@ -0,0 +1,67 @@ +package app + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/hex" + "errors" + "strings" +) + +var codeEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ"). + WithPadding(base32.NoPadding) + +func randomBytes(size int) ([]byte, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return nil, err + } + return value, nil +} + +func randomToken(size int) (string, error) { + value, err := randomBytes(size) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func deploymentCode() (string, error) { + value, err := randomBytes(7) + if err != nil { + return "", err + } + encoded := codeEncoding.EncodeToString(value) + if len(encoded) < 10 { + return "", errors.New("generated deployment code is too short") + } + return "TAPM-" + encoded[:5] + "-" + encoded[5:10], nil +} + +func normalizeCode(value string) string { + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), " ", "")) +} + +func hashValue(value string) [32]byte { + return sha256.Sum256([]byte(value)) +} + +func hashHex(value string) string { + sum := hashValue(value) + return hex.EncodeToString(sum[:]) +} + +func signValue(secret []byte, value string) string { + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(value)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func verifySignature(secret []byte, value, signature string) bool { + expected := signValue(secret, value) + return hmac.Equal([]byte(expected), []byte(signature)) +} diff --git a/internal/app/security_test.go b/internal/app/security_test.go new file mode 100644 index 0000000..4926012 --- /dev/null +++ b/internal/app/security_test.go @@ -0,0 +1,30 @@ +package app + +import ( + "strings" + "testing" +) + +func TestDeploymentCodeFormatAndNormalization(t *testing.T) { + code, err := deploymentCode() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(code, "TAPM-") || len(code) != 16 { + t.Fatalf("unexpected code format: %q", code) + } + if got := normalizeCode(strings.ToLower(code)); got != code { + t.Fatalf("normalizeCode() = %q, want %q", got, code) + } +} + +func TestSignature(t *testing.T) { + secret := []byte("01234567890123456789012345678901") + signature := signValue(secret, "state") + if !verifySignature(secret, "state", signature) { + t.Fatal("valid signature was rejected") + } + if verifySignature(secret, "different", signature) { + t.Fatal("invalid signature was accepted") + } +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..53d5049 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,260 @@ +package app + +import ( + "context" + "database/sql" + "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +//go:embed templates/*.html static/* +var webFiles embed.FS + +type Server struct { + cfg Config + db *sql.DB + templates *template.Template + client *http.Client + packageClient *http.Client +} + +type technician struct { + Login string + DisplayName string + CSRFToken string +} + +type packageRecord struct { + ID uint64 + Slug string + DisplayName string + PackageName string + PackageVersion string + FileName string + SHA256 string + Enabled bool +} + +type authorizationRecord struct { + ID uint64 + CodeHint string + CreatedBy string + CustomerLabel string + HostLimit int + HostCount int + CreatedAt time.Time + ExpiresAt time.Time + RevokedAt sql.NullTime + Packages string +} + +type auditRecord struct { + EventType string + Actor string + Hostname string + PackageSlug string + SourceIP string + Details string + CreatedAt time.Time +} + +type pageData struct { + Title string + Technician *technician + CSRFToken string + Authorizations []authorizationRecord + AuditEvents []auditRecord + Packages []packageRecord + NewCode string + DefaultHostLimit int + DefaultDuration string + Error string + Notice string + GiteaLoginURL string +} + +func New(cfg Config) (*Server, error) { + db, err := sql.Open("mysql", cfg.DatabaseDSN) + if err != nil { + return nil, err + } + db.SetConnMaxLifetime(5 * time.Minute) + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(10) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("database: %w", err) + } + + templates, err := template.New("").Funcs(template.FuncMap{ + "formatTime": func(value time.Time) string { + return value.Local().Format("Jan 2, 2006 3:04 PM") + }, + "isActive": func(record authorizationRecord) bool { + return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt) + }, + }).ParseFS(webFiles, "templates/*.html") + if err != nil { + _ = db.Close() + return nil, err + } + + return &Server{ + cfg: cfg, + db: db, + templates: templates, + client: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + return nil + }, + }, + packageClient: &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 90 * time.Second, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + return nil + }, + }, + }, nil +} + +func (s *Server) Close() error { + return s.db.Close() +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health/live", s.handleLive) + mux.HandleFunc("GET /health/ready", s.handleReady) + mux.HandleFunc("GET /auth/login", s.handleLogin) + mux.HandleFunc("GET /auth/callback", s.handleCallback) + mux.HandleFunc("POST /auth/logout", s.handleLogout) + mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal)) + mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization)) + mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) + mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage)) + mux.HandleFunc("POST /api/v1/exchange", s.handleExchange) + mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload) + mux.HandleFunc("GET /", s.handleHome) + mux.Handle("GET /static/", http.FileServerFS(webFiles)) + return s.securityHeaders(s.requestLog(mux)) +} + +func (s *Server) handleLive(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "live"}) +} + +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.db.PingContext(ctx); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "database unavailable"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + tech, _ := s.currentTechnician(r) + if tech != nil { + http.Redirect(w, r, "/portal", http.StatusSeeOther) + return + } + s.render(w, "login.html", pageData{ + Title: "TAPM Deployment Access", + GiteaLoginURL: "/auth/login", + }) +} + +func (s *Server) render(w http.ResponseWriter, name string, data pageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { + log.Printf("render %s: %v", name, err) + } +} + +func (s *Server) clientIP(r *http.Request) string { + if s.cfg.TrustProxyHeaders { + if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { + return forwarded + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) requestLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s ip=%s duration=%s", r.Method, r.URL.Path, s.clientIP(r), time.Since(started).Round(time.Millisecond)) + }) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func parseUintPath(r *http.Request, name string) (uint64, error) { + return strconv.ParseUint(r.PathValue(name), 10, 64) +} + +func joinURL(base *url.URL, path string) string { + result := *base + result.Path = strings.TrimRight(result.Path, "/") + "/" + strings.TrimLeft(path, "/") + return result.String() +} + +func copyResponse(w http.ResponseWriter, response *http.Response) { + for _, header := range []string{"Content-Type", "Content-Length", "Content-Disposition"} { + if value := response.Header.Get(header); value != "" { + w.Header().Set(header, value) + } + } + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, response.Body) +} diff --git a/internal/app/static/app.css b/internal/app/static/app.css new file mode 100644 index 0000000..d903a4a --- /dev/null +++ b/internal/app/static/app.css @@ -0,0 +1,256 @@ +:root { + color-scheme: dark; + --ink: #edf4f2; + --muted: #95a8a3; + --panel: #111d1c; + --panel-2: #172624; + --line: #28413d; + --accent: #58e0ad; + --accent-dark: #143e32; + --amber: #f1bd6c; + --danger: #ff7f75; + --shadow: 0 24px 70px rgba(0, 0, 0, .28); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(circle at 12% 0%, rgba(88, 224, 173, .1), transparent 30rem), + #091211; +} + +button, input { font: inherit; } + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 68px; + padding: 0 5vw; + border-bottom: 1px solid rgba(88, 224, 173, .16); + background: rgba(9, 18, 17, .88); + backdrop-filter: blur(18px); +} + +.wordmark { + margin-right: 12px; + color: var(--accent); + font-weight: 900; + letter-spacing: .14em; +} + +.product-name, .operator { color: var(--muted); } +.operator { display: flex; align-items: center; gap: 18px; } +.operator form { margin: 0; } + +.page { + width: min(1380px, 92vw); + margin: 0 auto; + padding: 56px 0 90px; +} + +.hero { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + gap: 40px; + margin-bottom: 38px; +} + +h1, h2, p { margin-top: 0; } +h1 { max-width: 760px; margin-bottom: 14px; font-size: clamp(2.5rem, 6vw, 5.7rem); line-height: .94; letter-spacing: -.055em; } +h2 { margin-bottom: 0; font-size: 1.4rem; letter-spacing: -.02em; } +.hero p:not(.eyebrow) { max-width: 650px; color: var(--muted); font-size: 1.1rem; line-height: 1.65; } + +.eyebrow { + margin-bottom: 10px; + color: var(--accent); + font-size: .72rem; + font-weight: 800; + letter-spacing: .14em; + text-transform: uppercase; +} + +.policy-card { + display: grid; + min-width: 190px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--panel); +} +.policy-card span { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; } +.policy-card strong { margin-top: 7px; font-size: 1.25rem; } + +.content-grid { + display: grid; + grid-template-columns: minmax(330px, .8fr) minmax(460px, 1.2fr); + gap: 24px; +} + +.panel { + border: 1px solid var(--line); + border-radius: 22px; + background: linear-gradient(145deg, rgba(23, 38, 36, .9), rgba(14, 25, 24, .96)); + box-shadow: var(--shadow); + overflow: hidden; +} + +.panel-heading { padding: 26px 28px 20px; border-bottom: 1px solid var(--line); } +.stack { display: grid; gap: 18px; padding: 26px 28px 30px; } +.stack.compact { padding: 0; } + +label, legend { color: var(--muted); font-size: .78rem; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; } +input { + width: 100%; + margin-top: 8px; + padding: 12px 13px; + color: var(--ink); + border: 1px solid #35504b; + border-radius: 10px; + outline: none; + background: #0a1514; +} +input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(88, 224, 173, .12); } +.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } + +fieldset { display: grid; gap: 10px; padding: 0; border: 0; } +legend { margin-bottom: 8px; } +.package-choice { + display: flex; + align-items: center; + gap: 12px; + padding: 13px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(8, 18, 17, .55); + text-transform: none; +} +.package-choice input, .toggle input { width: auto; margin: 0; accent-color: var(--accent); } +.package-choice span { display: grid; gap: 3px; } +.package-choice strong { color: var(--ink); font-size: .94rem; } +.package-choice small { color: var(--muted); } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + padding: 0 18px; + color: #07110e; + border: 0; + border-radius: 11px; + background: var(--accent); + font-weight: 850; + text-decoration: none; + cursor: pointer; +} +.button.secondary { color: var(--ink); border: 1px solid var(--line); background: #1c302d; } +.button.light { background: #e9fff7; } +.button.wide { width: 100%; } +.text-button { padding: 0; color: var(--accent); border: 0; background: transparent; cursor: pointer; } +.text-button.danger { color: var(--danger); } + +.authorization-list { display: grid; max-height: 690px; overflow: auto; } +.authorization { padding: 21px 26px; border-bottom: 1px solid var(--line); } +.authorization:last-child { border-bottom: 0; } +.authorization.closed { opacity: .64; } +.authorization-top { display: flex; justify-content: space-between; gap: 20px; } +.authorization-top > div { display: flex; align-items: center; gap: 10px; } +.status { + display: inline-flex; + padding: 4px 8px; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + font-size: .66rem; + font-weight: 800; + letter-spacing: .06em; + text-transform: uppercase; +} +.authorization.active .status, .status.enabled { color: var(--accent); border-color: rgba(88, 224, 173, .4); background: rgba(88, 224, 173, .08); } +.code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; } +dl div { min-width: 0; } +dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; } +dd { margin: 4px 0 0; overflow-wrap: anywhere; } + +.code-reveal { + display: flex; + align-items: center; + justify-content: space-between; + gap: 30px; + margin-bottom: 32px; + padding: 24px 28px; + color: #092119; + border-radius: 18px; + background: var(--accent); + box-shadow: 0 20px 70px rgba(88, 224, 173, .2); +} +.code-reveal .eyebrow { color: #174e3c; } +.code-reveal h2 { margin-bottom: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(1.7rem, 4vw, 2.8rem); letter-spacing: .08em; } +.code-reveal p { margin-bottom: 0; } +.notice { margin-bottom: 22px; padding: 12px 16px; color: var(--accent); border: 1px solid rgba(88, 224, 173, .3); border-radius: 10px; background: rgba(88, 224, 173, .07); } + +.package-admin { margin-top: 24px; } +.package-layout { display: grid; grid-template-columns: .8fr 1.2fr; gap: 32px; padding: 28px; } +.package-table { border-right: 1px solid var(--line); padding-right: 30px; } +.package-row { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); } +.package-row div { display: grid; gap: 4px; } +.package-row small { color: var(--muted); } +.toggle { display: flex; align-items: center; gap: 9px; } +.empty, .fine-print { color: var(--muted); } + +.audit-panel { margin-top: 24px; } +.audit-table { display: grid; max-height: 520px; overflow: auto; } +.audit-row { + display: grid; + grid-template-columns: minmax(190px, .8fr) minmax(190px, 1fr) minmax(220px, 1.2fr); + gap: 22px; + padding: 15px 28px; + border-bottom: 1px solid var(--line); +} +.audit-row:last-child { border-bottom: 0; } +.audit-row > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.audit-row span, .audit-row small { color: var(--muted); overflow-wrap: anywhere; } +.audit-row strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .86rem; } +.audit-row small { font-size: .72rem; } +.audit-empty { padding: 20px 28px; } + +.login-page { display: grid; place-items: center; } +.login-shell { width: min(510px, 92vw); } +.login-card { + padding: clamp(30px, 7vw, 58px); + border: 1px solid var(--line); + border-radius: 28px; + background: linear-gradient(145deg, rgba(23, 38, 36, .96), rgba(10, 20, 19, .98)); + box-shadow: var(--shadow); +} +.brand-mark { margin-bottom: 42px; color: var(--accent); font-size: 1.1rem; font-weight: 950; letter-spacing: .18em; } +.login-card h1 { font-size: clamp(2.8rem, 10vw, 4.8rem); } +.lede { margin-bottom: 32px; color: var(--muted); line-height: 1.7; } +.fine-print { margin: 22px 0 0; font-size: .76rem; text-align: center; } + +@media (max-width: 900px) { + .hero, .content-grid, .package-layout { grid-template-columns: 1fr; } + .audit-row { grid-template-columns: 1fr 1fr; } + .audit-row > div:last-child { grid-column: 1 / -1; } + .policy-card { width: 100%; } + .package-table { border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 24px; } +} + +@media (max-width: 580px) { + .topbar { padding: 0 4vw; } + .product-name, .operator > span { display: none; } + .page { width: 94vw; padding-top: 34px; } + .field-row, dl { grid-template-columns: 1fr; } + .code-reveal { align-items: stretch; flex-direction: column; } +} diff --git a/internal/app/static/app.js b/internal/app/static/app.js new file mode 100644 index 0000000..eb6dcde --- /dev/null +++ b/internal/app/static/app.js @@ -0,0 +1,16 @@ +document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy]"); + if (!button) return; + const target = document.querySelector(button.dataset.copy); + if (!target) return; + try { + await navigator.clipboard.writeText(target.textContent.trim()); + const original = button.textContent; + button.textContent = "Copied"; + window.setTimeout(() => { + button.textContent = original; + }, 1500); + } catch { + button.textContent = "Copy failed"; + } +}); diff --git a/internal/app/templates/login.html b/internal/app/templates/login.html new file mode 100644 index 0000000..c29fca0 --- /dev/null +++ b/internal/app/templates/login.html @@ -0,0 +1,26 @@ +{{define "login.html"}} + + + + + + {{.Title}} + + + +
+ +
+ + +{{end}} diff --git a/internal/app/templates/portal.html b/internal/app/templates/portal.html new file mode 100644 index 0000000..ff5b24b --- /dev/null +++ b/internal/app/templates/portal.html @@ -0,0 +1,213 @@ +{{define "portal.html"}} + + + + + + {{.Title}} · TAPM + + + + +
+
+ TAPM + Deployment Access +
+
+ {{.Technician.DisplayName}} +
+ + +
+
+
+ +
+ {{if .NewCode}} +
+
+

Deployment authorization created

+

{{.NewCode}}

+

Share this code only with the technician performing the deployment.

+
+ +
+ {{end}} + + {{if .Notice}}
{{.Notice}}
{{end}} + +
+
+

Controlled package delivery

+

Open a deployment window.

+

+ Authorize a limited number of hosts for selected packages. Access + closes automatically at the end of the window. +

+
+
+ Default policy + {{.DefaultHostLimit}} hosts + {{.DefaultDuration}} hours +
+
+ +
+
+
+
+

New authorization

+

Create deployment code

+
+
+ +
+ + + +
+ + +
+ +
+ Allowed packages + {{range .Packages}} + {{if .Enabled}} + + {{end}} + {{else}} +

No packages have been enabled yet.

+ {{end}} +
+ + +
+
+ +
+
+
+

Recent activity

+

Deployment authorizations

+
+
+ +
+ {{range .Authorizations}} +
+
+
+ {{if .CustomerLabel}}{{.CustomerLabel}}{{else}}Unlabeled deployment{{end}} + {{if isActive .}}Active{{else}}Closed{{end}} +
+ •••••{{.CodeHint}} +
+
+
Hosts
{{.HostCount}} / {{.HostLimit}}
+
Expires
{{formatTime .ExpiresAt}}
+
Packages
{{.Packages}}
+
Created by
{{.CreatedBy}}
+
+ {{if isActive .}} +
+ + +
+ {{end}} +
+ {{else}} +

No deployment authorizations have been created.

+ {{end}} +
+
+
+ +
+
+
+

Registry catalog

+

Protected packages

+
+
+ +
+
+ {{range .Packages}} +
+
+ {{.DisplayName}} + {{.Slug}} · {{.PackageVersion}} +
+ {{if .Enabled}}Enabled{{else}}Disabled{{end}} +
+ {{else}} +

Add the first package after publishing it to Gitea.

+ {{end}} +
+ +
+ + + +
+ + +
+ + + + +
+
+
+ +
+
+
+

Audit trail

+

Latest security events

+
+
+
+ {{range .AuditEvents}} +
+
+ {{.EventType}} + {{formatTime .CreatedAt}} +
+
+ {{if .Actor}}{{.Actor}}{{end}} + {{if .Hostname}}{{.Hostname}}{{end}} + {{if .PackageSlug}}{{.PackageSlug}}{{end}} +
+
+ {{.SourceIP}} + {{if .Details}}{{.Details}}{{end}} +
+
+ {{else}} +

No security events have been recorded.

+ {{end}} +
+
+
+ + +{{end}} diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..3927213 --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,103 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT UNSIGNED NOT NULL PRIMARY KEY, + applied_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS technician_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + token_hash BINARY(32) NOT NULL UNIQUE, + csrf_token VARCHAR(64) NOT NULL, + gitea_login VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + expires_at TIMESTAMP(6) NOT NULL, + last_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_technician_sessions_expires (expires_at) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS packages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(100) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + package_name VARCHAR(255) NOT NULL, + package_version VARCHAR(100) NOT NULL, + file_name VARCHAR(255) NOT NULL, + sha256 CHAR(64) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + ON UPDATE CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS authorizations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + code_hash BINARY(32) NOT NULL UNIQUE, + code_hint VARCHAR(8) NOT NULL, + created_by VARCHAR(255) NOT NULL, + customer_label VARCHAR(255) NOT NULL DEFAULT '', + host_limit INT UNSIGNED NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + INDEX idx_authorizations_expires (expires_at), + INDEX idx_authorizations_created_by (created_by, created_at) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS authorization_packages ( + authorization_id BIGINT UNSIGNED NOT NULL, + package_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (authorization_id, package_id), + CONSTRAINT fk_authorization_packages_authorization + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) + ON DELETE CASCADE, + CONSTRAINT fk_authorization_packages_package + FOREIGN KEY (package_id) REFERENCES packages(id) + ON DELETE RESTRICT +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS authorization_hosts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + authorization_id BIGINT UNSIGNED NOT NULL, + host_fingerprint BINARY(32) NOT NULL, + hostname VARCHAR(255) NOT NULL, + first_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_seen_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_authorization_host (authorization_id, host_fingerprint), + CONSTRAINT fk_authorization_hosts_authorization + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) + ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS download_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + token_hash BINARY(32) NOT NULL UNIQUE, + authorization_id BIGINT UNSIGNED NOT NULL, + authorization_host_id BIGINT UNSIGNED NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + INDEX idx_download_sessions_expires (expires_at), + CONSTRAINT fk_download_sessions_authorization + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) + ON DELETE CASCADE, + CONSTRAINT fk_download_sessions_host + FOREIGN KEY (authorization_host_id) REFERENCES authorization_hosts(id) + ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS audit_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(100) NOT NULL, + actor VARCHAR(255) NOT NULL DEFAULT '', + authorization_id BIGINT UNSIGNED NULL, + hostname VARCHAR(255) NOT NULL DEFAULT '', + package_slug VARCHAR(100) NOT NULL DEFAULT '', + source_ip VARCHAR(64) NOT NULL DEFAULT '', + details TEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_audit_created (created_at), + INDEX idx_audit_authorization (authorization_id, created_at), + INDEX idx_audit_exchange_limit (event_type, source_ip, created_at) +) ENGINE=InnoDB; + +INSERT IGNORE INTO schema_migrations (version) VALUES (1);