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