initial upload

This commit is contained in:
2026-07-25 13:11:37 -05:00
commit 943ddb064f
25 changed files with 2586 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.env
.DS_Store
README.md
docs
deploy
+15
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
.DS_Store
/bin/
/dist/
*.log
+21
View File
@@ -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"]
+35
View File
@@ -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 .
```
+94
View File
@@ -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())
}
}
+55
View File
@@ -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)
}
}
+29
View File
@@ -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
+21
View File
@@ -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;
}
}
+64
View File
@@ -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.
+153
View File
@@ -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.
+7
View File
@@ -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
+4
View File
@@ -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=
+280
View File
@@ -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)
}
+366
View File
@@ -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")
+133
View File
@@ -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
}
+327
View File
@@ -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), "")
}
+67
View File
@@ -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))
}
+30
View File
@@ -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")
}
}
+260
View File
@@ -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)
}
+256
View File
@@ -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; }
}
+16
View File
@@ -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";
}
});
+26
View File
@@ -0,0 +1,26 @@
{{define "login.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-page">
<main class="login-shell">
<section class="login-card">
<div class="brand-mark">TAPM</div>
<p class="eyebrow">Technician authorization</p>
<h1>Deployment Access</h1>
<p class="lede">
Create time-limited access for protected packages without placing
permanent credentials on customer infrastructure.
</p>
<a class="button primary wide" href="{{.GiteaLoginURL}}">Continue with Gitea</a>
<p class="fine-print">Authorized TAI technicians only. Activity is audited.</p>
</section>
</main>
</body>
</html>
{{end}}
+213
View File
@@ -0,0 +1,213 @@
{{define "portal.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · TAPM</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<header class="topbar">
<div>
<span class="wordmark">TAPM</span>
<span class="product-name">Deployment Access</span>
</div>
<div class="operator">
<span>{{.Technician.DisplayName}}</span>
<form action="/auth/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="text-button" type="submit">Sign out</button>
</form>
</div>
</header>
<main class="page">
{{if .NewCode}}
<section class="code-reveal" aria-live="polite">
<div>
<p class="eyebrow">Deployment authorization created</p>
<h2 id="new-code">{{.NewCode}}</h2>
<p>Share this code only with the technician performing the deployment.</p>
</div>
<button class="button light" type="button" data-copy="#new-code">Copy code</button>
</section>
{{end}}
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
<section class="hero">
<div>
<p class="eyebrow">Controlled package delivery</p>
<h1>Open a deployment window.</h1>
<p>
Authorize a limited number of hosts for selected packages. Access
closes automatically at the end of the window.
</p>
</div>
<div class="policy-card">
<span>Default policy</span>
<strong>{{.DefaultHostLimit}} hosts</strong>
<strong>{{.DefaultDuration}} hours</strong>
</div>
</section>
<div class="content-grid">
<section class="panel create-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">New authorization</p>
<h2>Create deployment code</h2>
</div>
</div>
<form action="/portal/authorizations" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Customer or deployment label
<input name="customer_label" maxlength="255" placeholder="Example: Acme cluster refresh">
</label>
<div class="field-row">
<label>
Maximum hosts
<input name="host_limit" type="number" min="1" max="25" value="{{.DefaultHostLimit}}" required>
</label>
<label>
Duration in hours
<input name="duration_hours" type="number" min="1" max="24" value="{{.DefaultDuration}}" required>
</label>
</div>
<fieldset>
<legend>Allowed packages</legend>
{{range .Packages}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="package_id" value="{{.ID}}">
<span>
<strong>{{.DisplayName}}</strong>
<small>{{.PackageVersion}}</small>
</span>
</label>
{{end}}
{{else}}
<p class="empty">No packages have been enabled yet.</p>
{{end}}
</fieldset>
<button class="button primary" type="submit">Create 3-hour code</button>
</form>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Recent activity</p>
<h2>Deployment authorizations</h2>
</div>
</div>
<div class="authorization-list">
{{range .Authorizations}}
<article class="authorization {{if isActive .}}active{{else}}closed{{end}}">
<div class="authorization-top">
<div>
<strong>{{if .CustomerLabel}}{{.CustomerLabel}}{{else}}Unlabeled deployment{{end}}</strong>
<span class="status">{{if isActive .}}Active{{else}}Closed{{end}}</span>
</div>
<span class="code-hint">•••••{{.CodeHint}}</span>
</div>
<dl>
<div><dt>Hosts</dt><dd>{{.HostCount}} / {{.HostLimit}}</dd></div>
<div><dt>Expires</dt><dd>{{formatTime .ExpiresAt}}</dd></div>
<div><dt>Packages</dt><dd>{{.Packages}}</dd></div>
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl>
{{if isActive .}}
<form action="/portal/authorizations/{{.ID}}/revoke" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="text-button danger" type="submit">Revoke now</button>
</form>
{{end}}
</article>
{{else}}
<p class="empty">No deployment authorizations have been created.</p>
{{end}}
</div>
</section>
</div>
<section class="panel package-admin">
<div class="panel-heading">
<div>
<p class="eyebrow">Registry catalog</p>
<h2>Protected packages</h2>
</div>
</div>
<div class="package-layout">
<div class="package-table">
{{range .Packages}}
<div class="package-row">
<div>
<strong>{{.DisplayName}}</strong>
<small>{{.Slug}} · {{.PackageVersion}}</small>
</div>
<span class="status {{if .Enabled}}enabled{{end}}">{{if .Enabled}}Enabled{{else}}Disabled{{end}}</span>
</div>
{{else}}
<p class="empty">Add the first package after publishing it to Gitea.</p>
{{end}}
</div>
<form action="/portal/packages" method="post" class="stack compact">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<div class="field-row">
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label>
<label>Version <input name="package_version" placeholder="26.1.1.31" required></label>
</div>
<label>Filename <input name="file_name" placeholder="SentinelAgent_linux_x86_64.deb" required></label>
<label>SHA-256 <input name="sha256" minlength="64" maxlength="64" placeholder="64 hexadecimal characters" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label>
<button class="button secondary" type="submit">Save package metadata</button>
</form>
</div>
</section>
<section class="panel audit-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Audit trail</p>
<h2>Latest security events</h2>
</div>
</div>
<div class="audit-table">
{{range .AuditEvents}}
<div class="audit-row">
<div>
<strong>{{.EventType}}</strong>
<small>{{formatTime .CreatedAt}}</small>
</div>
<div>
{{if .Actor}}<span>{{.Actor}}</span>{{end}}
{{if .Hostname}}<span>{{.Hostname}}</span>{{end}}
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
</div>
<div>
<span>{{.SourceIP}}</span>
{{if .Details}}<small>{{.Details}}</small>{{end}}
</div>
</div>
{{else}}
<p class="empty audit-empty">No security events have been recorded.</p>
{{end}}
</div>
</section>
</main>
</body>
</html>
{{end}}
+103
View File
@@ -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);