initial upload
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
|
docs
|
||||||
|
deploy
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
*.log
|
||||||
+21
@@ -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"]
|
||||||
@@ -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 .
|
||||||
|
```
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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), "")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user