initial upload

This commit is contained in:
David Schroeder
2026-07-25 13:11:37 -05:00
commit 943ddb064f
25 changed files with 2586 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
sessionCookieName = "tapm_session"
oauthCookieName = "tapm_oauth_state"
)
type giteaTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
type giteaUser struct {
Login string `json:"login"`
FullName string `json:"full_name"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
state, err := randomToken(24)
if err != nil {
http.Error(w, "unable to start sign-in", http.StatusInternalServerError)
return
}
signedState := state + "." + signValue(s.cfg.CookieSecret, state)
http.SetCookie(w, &http.Cookie{
Name: oauthCookieName,
Value: signedState,
Path: "/auth",
MaxAge: 600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback"
query := url.Values{
"client_id": {s.cfg.GiteaClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"state": {state},
}
http.Redirect(
w,
r,
joinURL(s.cfg.GiteaURL, "/login/oauth/authorize")+"?"+query.Encode(),
http.StatusFound,
)
}
func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(oauthCookieName)
if err != nil {
http.Error(w, "sign-in state is missing", http.StatusBadRequest)
return
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 ||
!verifySignature(s.cfg.CookieSecret, parts[0], parts[1]) ||
r.URL.Query().Get("state") != parts[0] {
http.Error(w, "sign-in state is invalid", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: oauthCookieName,
Value: "",
Path: "/auth",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "authorization code is missing", http.StatusBadRequest)
return
}
user, err := s.exchangeOAuthCode(r.Context(), code)
if err != nil {
http.Error(w, "Gitea sign-in failed", http.StatusBadGateway)
return
}
if _, allowed := s.cfg.AllowedGiteaUsers[strings.ToLower(user.Login)]; !allowed {
_ = s.audit(r.Context(), "login_denied", user.Login, nil, "", "", s.clientIP(r), "user is not allowed")
http.Error(w, "this Gitea user is not authorized", http.StatusForbidden)
return
}
sessionToken, err := randomToken(32)
if err != nil {
http.Error(w, "unable to create session", http.StatusInternalServerError)
return
}
csrfToken, err := randomToken(24)
if err != nil {
http.Error(w, "unable to create session", http.StatusInternalServerError)
return
}
tokenHash := hashValue(sessionToken)
displayName := strings.TrimSpace(user.FullName)
if displayName == "" {
displayName = user.Login
}
expiresAt := time.Now().UTC().Add(12 * time.Hour)
_, err = s.db.ExecContext(
r.Context(),
`INSERT INTO technician_sessions
(token_hash, csrf_token, gitea_login, display_name, expires_at)
VALUES (?, ?, ?, ?, ?)`,
tokenHash[:], csrfToken, user.Login, displayName, expiresAt,
)
if err != nil {
http.Error(w, "unable to save session", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionToken,
Path: "/",
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
_ = s.audit(r.Context(), "login_succeeded", user.Login, nil, "", "", s.clientIP(r), "")
http.Redirect(w, r, "/portal", http.StatusSeeOther)
}
func (s *Server) exchangeOAuthCode(ctx context.Context, code string) (giteaUser, error) {
var user giteaUser
redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback"
form := url.Values{
"client_id": {s.cfg.GiteaClientID},
"client_secret": {s.cfg.GiteaClientSecret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {redirectURI},
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
joinURL(s.cfg.GiteaURL, "/login/oauth/access_token"),
strings.NewReader(form.Encode()),
)
if err != nil {
return user, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := s.client.Do(request)
if err != nil {
return user, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("token endpoint returned %s", response.Status)
}
var token giteaTokenResponse
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
return user, err
}
if token.AccessToken == "" {
return user, fmt.Errorf("token endpoint returned an empty token")
}
request, err = http.NewRequestWithContext(
ctx,
http.MethodGet,
joinURL(s.cfg.GiteaURL, "/api/v1/user"),
nil,
)
if err != nil {
return user, err
}
request.Header.Set("Authorization", "token "+token.AccessToken)
response, err = s.client.Do(request)
if err != nil {
return user, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("user endpoint returned %s", response.Status)
}
if err := json.NewDecoder(response.Body).Decode(&user); err != nil {
return user, err
}
if user.Login == "" {
return user, fmt.Errorf("user endpoint returned an empty login")
}
return user, nil
}
func (s *Server) currentTechnician(r *http.Request) (*technician, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, err
}
tokenHash := hashValue(cookie.Value)
var tech technician
err = s.db.QueryRowContext(
r.Context(),
`SELECT gitea_login, display_name, csrf_token
FROM technician_sessions
WHERE token_hash = ? AND expires_at > UTC_TIMESTAMP(6)`,
tokenHash[:],
).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken)
if err != nil {
return nil, err
}
_, _ = s.db.ExecContext(
r.Context(),
`UPDATE technician_sessions SET last_seen_at = UTC_TIMESTAMP(6)
WHERE token_hash = ?`,
tokenHash[:],
)
return &tech, nil
}
func (s *Server) requireTechnician(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tech, err := s.currentTechnician(r)
if err != nil {
if !errorsIsNoRowsOrCookie(err) {
http.Error(w, "unable to validate session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method != http.MethodGet && r.FormValue("csrf_token") != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
}
next(w, r)
}
}
func errorsIsNoRowsOrCookie(err error) bool {
return err == http.ErrNoCookie || err == sql.ErrNoRows
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
tech, err := s.currentTechnician(r)
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.FormValue("csrf_token") != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
tokenHash := hashValue(cookie.Value)
_, _ = s.db.ExecContext(r.Context(), `DELETE FROM technician_sessions WHERE token_hash = ?`, tokenHash[:])
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+366
View File
@@ -0,0 +1,366 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
tech, err := s.currentTechnician(r)
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
packages, err := s.listPackages(r)
if err != nil {
http.Error(w, "unable to list packages", http.StatusInternalServerError)
return
}
authorizations, err := s.listAuthorizations(r)
if err != nil {
http.Error(w, "unable to list authorizations", http.StatusInternalServerError)
return
}
auditEvents, err := s.listAuditEvents(r)
if err != nil {
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
return
}
s.render(w, "portal.html", pageData{
Title: "Deployment Access",
Technician: tech,
CSRFToken: tech.CSRFToken,
Authorizations: authorizations,
AuditEvents: auditEvents,
Packages: packages,
NewCode: r.URL.Query().Get("code"),
DefaultHostLimit: s.cfg.DefaultHostLimit,
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())),
Notice: r.URL.Query().Get("notice"),
})
}
func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at
FROM audit_events
ORDER BY created_at DESC
LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []auditRecord
for rows.Next() {
var record auditRecord
if err := rows.Scan(
&record.EventType,
&record.Actor,
&record.Hostname,
&record.PackageSlug,
&record.SourceIP,
&record.Details,
&record.CreatedAt,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) listPackages(r *http.Request) ([]packageRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT id, slug, display_name, package_name, package_version,
file_name, sha256, enabled
FROM packages
ORDER BY display_name, package_version`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []packageRecord
for rows.Next() {
var record packageRecord
if err := rows.Scan(
&record.ID,
&record.Slug,
&record.DisplayName,
&record.PackageName,
&record.PackageVersion,
&record.FileName,
&record.SHA256,
&record.Enabled,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT a.id, a.code_hint, a.created_by, a.customer_label,
a.host_limit, COUNT(DISTINCT h.id), a.created_at,
a.expires_at, a.revoked_at,
COALESCE(GROUP_CONCAT(DISTINCT p.display_name
ORDER BY p.display_name SEPARATOR ', '), '')
FROM authorizations a
LEFT JOIN authorization_hosts h ON h.authorization_id = a.id
LEFT JOIN authorization_packages ap ON ap.authorization_id = a.id
LEFT JOIN packages p ON p.id = ap.package_id
WHERE a.created_at > UTC_TIMESTAMP(6) - INTERVAL 30 DAY
GROUP BY a.id
ORDER BY a.created_at DESC
LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []authorizationRecord
for rows.Next() {
var record authorizationRecord
if err := rows.Scan(
&record.ID,
&record.CodeHint,
&record.CreatedBy,
&record.CustomerLabel,
&record.HostLimit,
&record.HostCount,
&record.CreatedAt,
&record.ExpiresAt,
&record.RevokedAt,
&record.Packages,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
hostLimit, err := strconv.Atoi(r.FormValue("host_limit"))
if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit {
http.Error(w, "invalid host limit", http.StatusBadRequest)
return
}
durationHours, err := strconv.Atoi(r.FormValue("duration_hours"))
if err != nil || durationHours < 1 || durationHours > 24 {
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest)
return
}
packageIDs := r.Form["package_id"]
if len(packageIDs) == 0 {
http.Error(w, "select at least one package", http.StatusBadRequest)
return
}
code, err := deploymentCode()
if err != nil {
http.Error(w, "unable to generate code", http.StatusInternalServerError)
return
}
codeHash := hashValue(code)
expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour)
customerLabel := strings.TrimSpace(r.FormValue("customer_label"))
if len(customerLabel) > 255 {
http.Error(w, "customer label is too long", http.StatusBadRequest)
return
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, "unable to create authorization", http.StatusInternalServerError)
return
}
defer tx.Rollback()
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorizations
(code_hash, code_hint, created_by, customer_label, host_limit, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
codeHash[:], code[len(code)-5:], tech.Login, customerLabel, hostLimit, expiresAt,
)
if err != nil {
http.Error(w, "unable to create authorization", http.StatusInternalServerError)
return
}
authorizationID, _ := result.LastInsertId()
for _, rawID := range packageIDs {
packageID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
http.Error(w, "invalid package selection", http.StatusBadRequest)
return
}
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorization_packages (authorization_id, package_id)
SELECT ?, id FROM packages WHERE id = ? AND enabled = TRUE`,
authorizationID, packageID,
)
if err != nil {
http.Error(w, "unable to authorize package", http.StatusInternalServerError)
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected package is unavailable", http.StatusBadRequest)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return
}
id := uint64(authorizationID)
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel))
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
}
func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
id, err := parseUintPath(r, "id")
if err != nil {
http.Error(w, "invalid authorization", http.StatusBadRequest)
return
}
result, err := s.db.ExecContext(
r.Context(),
`UPDATE authorizations
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(6))
WHERE id = ?`,
id,
)
if err != nil {
http.Error(w, "unable to revoke authorization", http.StatusInternalServerError)
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
http.Error(w, "authorization not found", http.StatusNotFound)
return
}
_, _ = s.db.ExecContext(
r.Context(),
`UPDATE download_sessions SET revoked_at = UTC_TIMESTAMP(6)
WHERE authorization_id = ? AND revoked_at IS NULL`,
id,
)
_ = s.audit(r.Context(), "authorization_revoked", tech.Login, &id, "", "", s.clientIP(r), "")
http.Redirect(w, r, "/portal?notice=Authorization+revoked", http.StatusSeeOther)
}
func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
fields := []string{
"slug", "display_name", "package_name", "package_version", "file_name", "sha256",
}
values := make(map[string]string)
for _, field := range fields {
values[field] = strings.TrimSpace(r.FormValue(field))
if values[field] == "" {
http.Error(w, field+" is required", http.StatusBadRequest)
return
}
}
if !validSlug(values["slug"]) || !validSHA256(values["sha256"]) {
http.Error(w, "invalid slug or SHA-256", http.StatusBadRequest)
return
}
enabled := r.FormValue("enabled") == "1"
_, err := s.db.ExecContext(
r.Context(),
`INSERT INTO packages
(slug, display_name, package_name, package_version, file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
package_name = VALUES(package_name),
package_version = VALUES(package_version),
file_name = VALUES(file_name),
sha256 = VALUES(sha256),
enabled = VALUES(enabled)`,
values["slug"],
values["display_name"],
values["package_name"],
values["package_version"],
values["file_name"],
strings.ToLower(values["sha256"]),
enabled,
)
if err != nil {
http.Error(w, "unable to save package", http.StatusInternalServerError)
return
}
_ = s.audit(r.Context(), "package_saved", tech.Login, nil, "", values["slug"], s.clientIP(r),
fmt.Sprintf("version=%s enabled=%t", values["package_version"], enabled))
http.Redirect(w, r, "/portal?notice=Package+saved", http.StatusSeeOther)
}
func validSlug(value string) bool {
if value == "" || len(value) > 100 {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') &&
(character < '0' || character > '9') &&
character != '-' && character != '_' {
return false
}
}
return true
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range strings.ToLower(value) {
if (character < '0' || character > '9') &&
(character < 'a' || character > 'f') {
return false
}
}
return true
}
func (s *Server) audit(
ctx context.Context,
eventType string,
actor string,
authorizationID *uint64,
hostname string,
packageSlug string,
sourceIP string,
details string,
) error {
var authID any
if authorizationID != nil {
authID = *authorizationID
}
_, err := s.db.ExecContext(
ctx,
`INSERT INTO audit_events
(event_type, actor, authorization_id, hostname, package_slug, source_ip, details)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventType, actor, authID, hostname, packageSlug, sourceIP, details,
)
return err
}
var errAuthorizationDenied = errors.New("authorization denied")
+133
View File
@@ -0,0 +1,133 @@
package app
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
ListenAddr string
PublicURL *url.URL
DatabaseDSN string
GiteaURL *url.URL
GiteaClientID string
GiteaClientSecret string
GiteaPackageOwner string
GiteaPackageUser string
GiteaPackageToken string
AllowedGiteaUsers map[string]struct{}
CookieSecret []byte
DefaultDuration time.Duration
DefaultHostLimit int
MaxHostLimit int
TrustProxyHeaders bool
}
func LoadConfig() (Config, error) {
var cfg Config
var err error
cfg.ListenAddr = envDefault("TAPM_LISTEN_ADDR", ":8080")
cfg.DatabaseDSN = os.Getenv("TAPM_DATABASE_DSN")
cfg.GiteaClientID = os.Getenv("TAPM_GITEA_CLIENT_ID")
cfg.GiteaClientSecret = os.Getenv("TAPM_GITEA_CLIENT_SECRET")
cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI")
cfg.GiteaPackageUser = os.Getenv("TAPM_GITEA_PACKAGE_USERNAME")
cfg.GiteaPackageToken = os.Getenv("TAPM_GITEA_PACKAGE_TOKEN")
cfg.CookieSecret = []byte(os.Getenv("TAPM_COOKIE_SECRET"))
cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL")
if err != nil {
return cfg, err
}
cfg.GiteaURL, err = parseAbsoluteURL("TAPM_GITEA_URL")
if err != nil {
return cfg, err
}
cfg.DefaultDuration, err = time.ParseDuration(
envDefault("TAPM_DEFAULT_DURATION", "3h"),
)
if err != nil || cfg.DefaultDuration <= 0 {
return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive")
}
cfg.DefaultHostLimit, err = envInt("TAPM_DEFAULT_HOST_LIMIT", 3)
if err != nil {
return cfg, err
}
cfg.MaxHostLimit, err = envInt("TAPM_MAX_HOST_LIMIT", 25)
if err != nil {
return cfg, err
}
if cfg.DefaultHostLimit < 1 || cfg.MaxHostLimit < cfg.DefaultHostLimit {
return cfg, fmt.Errorf("invalid default or maximum host limit")
}
cfg.TrustProxyHeaders, err = strconv.ParseBool(
envDefault("TAPM_TRUST_PROXY_HEADERS", "false"),
)
if err != nil {
return cfg, fmt.Errorf("TAPM_TRUST_PROXY_HEADERS: %w", err)
}
cfg.AllowedGiteaUsers = make(map[string]struct{})
for _, user := range strings.Split(
envDefault("TAPM_ALLOWED_GITEA_USERS", "taiadmin"),
",",
) {
user = strings.ToLower(strings.TrimSpace(user))
if user != "" {
cfg.AllowedGiteaUsers[user] = struct{}{}
}
}
required := map[string]string{
"TAPM_DATABASE_DSN": cfg.DatabaseDSN,
"TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID,
"TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret,
"TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser,
"TAPM_GITEA_PACKAGE_TOKEN": cfg.GiteaPackageToken,
}
for name, value := range required {
if strings.TrimSpace(value) == "" {
return cfg, fmt.Errorf("%s is required", name)
}
}
if len(cfg.CookieSecret) < 32 {
return cfg, fmt.Errorf("TAPM_COOKIE_SECRET must contain at least 32 bytes")
}
if len(cfg.AllowedGiteaUsers) == 0 {
return cfg, fmt.Errorf("at least one Gitea user must be allowed")
}
return cfg, nil
}
func parseAbsoluteURL(name string) (*url.URL, error) {
raw := os.Getenv(name)
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return nil, fmt.Errorf("%s must be an absolute HTTPS URL", name)
}
return parsed, nil
}
func envDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func envInt(name string, fallback int) (int, error) {
raw := envDefault(name, strconv.Itoa(fallback))
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", name)
}
return value, nil
}
+327
View File
@@ -0,0 +1,327 @@
package app
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type exchangeRequest struct {
Code string `json:"code"`
HostFingerprint string `json:"host_fingerprint"`
Hostname string `json:"hostname"`
}
type exchangePackage struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
DownloadURL string `json:"download_url"`
}
type exchangeResponse struct {
SessionToken string `json:"session_token"`
ExpiresAt time.Time `json:"expires_at"`
Packages []exchangePackage `json:"packages"`
}
func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
sourceIP := s.clientIP(r)
limited, err := s.exchangeRateLimited(r, sourceIP)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to validate request"})
return
}
if limited {
w.Header().Set("Retry-After", "600")
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many failed attempts; try again later"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
var request exchangeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
request.Code = normalizeCode(request.Code)
request.HostFingerprint = strings.TrimSpace(request.HostFingerprint)
request.Hostname = strings.TrimSpace(request.Hostname)
if request.Code == "" || len(request.HostFingerprint) < 16 ||
request.Hostname == "" || len(request.Hostname) > 255 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code and host identity are required"})
return
}
response, authorizationID, err := s.exchangeDeploymentCode(r, request)
if err != nil {
status := http.StatusInternalServerError
message := "unable to create download session"
if errors.Is(err, errAuthorizationDenied) {
status = http.StatusForbidden
message = "authorization is invalid, expired, revoked, or at its host limit"
}
_ = s.audit(r.Context(), "code_exchange_failed", "", nil, request.Hostname, "", sourceIP, err.Error())
writeJSON(w, status, map[string]string{"error": message})
return
}
_ = s.audit(r.Context(), "code_exchanged", "", &authorizationID, request.Hostname, "", sourceIP, "")
writeJSON(w, http.StatusOK, response)
}
func (s *Server) exchangeRateLimited(r *http.Request, sourceIP string) (bool, error) {
var attempts int
err := s.db.QueryRowContext(
r.Context(),
`SELECT COUNT(*)
FROM audit_events
WHERE event_type = 'code_exchange_failed'
AND source_ip = ?
AND created_at > UTC_TIMESTAMP(6) - INTERVAL 10 MINUTE`,
sourceIP,
).Scan(&attempts)
return attempts >= 10, err
}
func (s *Server) exchangeDeploymentCode(
r *http.Request,
request exchangeRequest,
) (exchangeResponse, uint64, error) {
var response exchangeResponse
codeHash := hashValue(request.Code)
fingerprintHash := hashValue(request.HostFingerprint)
tx, err := s.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return response, 0, err
}
defer tx.Rollback()
var authorizationID uint64
var hostLimit int
var expiresAt time.Time
err = tx.QueryRowContext(
r.Context(),
`SELECT id, host_limit, expires_at
FROM authorizations
WHERE code_hash = ?
AND revoked_at IS NULL
AND expires_at > UTC_TIMESTAMP(6)
FOR UPDATE`,
codeHash[:],
).Scan(&authorizationID, &hostLimit, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return response, 0, errAuthorizationDenied
}
if err != nil {
return response, 0, err
}
var hostID uint64
err = tx.QueryRowContext(
r.Context(),
`SELECT id FROM authorization_hosts
WHERE authorization_id = ? AND host_fingerprint = ?`,
authorizationID, fingerprintHash[:],
).Scan(&hostID)
if errors.Is(err, sql.ErrNoRows) {
var hostCount int
if err := tx.QueryRowContext(
r.Context(),
`SELECT COUNT(*) FROM authorization_hosts WHERE authorization_id = ?`,
authorizationID,
).Scan(&hostCount); err != nil {
return response, 0, err
}
if hostCount >= hostLimit {
return response, 0, errAuthorizationDenied
}
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorization_hosts
(authorization_id, host_fingerprint, hostname)
VALUES (?, ?, ?)`,
authorizationID, fingerprintHash[:], request.Hostname,
)
if err != nil {
return response, 0, err
}
insertedID, _ := result.LastInsertId()
hostID = uint64(insertedID)
} else if err != nil {
return response, 0, err
} else {
_, err = tx.ExecContext(
r.Context(),
`UPDATE authorization_hosts
SET hostname = ?, last_seen_at = UTC_TIMESTAMP(6)
WHERE id = ?`,
request.Hostname, hostID,
)
if err != nil {
return response, 0, err
}
}
sessionToken, err := randomToken(32)
if err != nil {
return response, 0, err
}
sessionHash := hashValue(sessionToken)
_, err = tx.ExecContext(
r.Context(),
`INSERT INTO download_sessions
(token_hash, authorization_id, authorization_host_id, expires_at)
VALUES (?, ?, ?, ?)`,
sessionHash[:], authorizationID, hostID, expiresAt,
)
if err != nil {
return response, 0, err
}
rows, err := tx.QueryContext(
r.Context(),
`SELECT p.slug, p.display_name, p.package_version, p.sha256
FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ? AND p.enabled = TRUE
ORDER BY p.display_name`,
authorizationID,
)
if err != nil {
return response, 0, err
}
defer rows.Close()
for rows.Next() {
var record exchangePackage
if err := rows.Scan(
&record.Slug,
&record.DisplayName,
&record.Version,
&record.SHA256,
); err != nil {
return response, 0, err
}
record.DownloadURL = strings.TrimRight(s.cfg.PublicURL.String(), "/") +
"/api/v1/packages/" + url.PathEscape(record.Slug)
response.Packages = append(response.Packages, record)
}
if err := rows.Err(); err != nil {
return response, 0, err
}
if len(response.Packages) == 0 {
return response, 0, errAuthorizationDenied
}
if err := tx.Commit(); err != nil {
return response, 0, err
}
response.SessionToken = sessionToken
response.ExpiresAt = expiresAt
return response, authorizationID, nil
}
func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
const bearerPrefix = "Bearer "
authorization := r.Header.Get("Authorization")
if !strings.HasPrefix(authorization, bearerPrefix) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "bearer token required"})
return
}
sessionToken := strings.TrimSpace(strings.TrimPrefix(authorization, bearerPrefix))
if sessionToken == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "bearer token required"})
return
}
slug := r.PathValue("slug")
sessionHash := hashValue(sessionToken)
var packageInfo packageRecord
var authorizationID uint64
var hostname string
err := s.db.QueryRowContext(
r.Context(),
`SELECT p.id, p.slug, p.display_name, p.package_name,
p.package_version, p.file_name, p.sha256, p.enabled,
ds.authorization_id, ah.hostname
FROM download_sessions ds
JOIN authorizations a ON a.id = ds.authorization_id
JOIN authorization_hosts ah ON ah.id = ds.authorization_host_id
JOIN authorization_packages ap ON ap.authorization_id = a.id
JOIN packages p ON p.id = ap.package_id
WHERE ds.token_hash = ?
AND ds.revoked_at IS NULL
AND ds.expires_at > UTC_TIMESTAMP(6)
AND a.revoked_at IS NULL
AND a.expires_at > UTC_TIMESTAMP(6)
AND p.slug = ?
AND p.enabled = TRUE`,
sessionHash[:], slug,
).Scan(
&packageInfo.ID,
&packageInfo.Slug,
&packageInfo.DisplayName,
&packageInfo.PackageName,
&packageInfo.PackageVersion,
&packageInfo.FileName,
&packageInfo.SHA256,
&packageInfo.Enabled,
&authorizationID,
&hostname,
)
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "package is not authorized"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to authorize package"})
return
}
registryURL := joinURL(
s.cfg.GiteaURL,
fmt.Sprintf(
"/api/packages/%s/generic/%s/%s/%s",
url.PathEscape(s.cfg.GiteaPackageOwner),
url.PathEscape(packageInfo.PackageName),
url.PathEscape(packageInfo.PackageVersion),
url.PathEscape(packageInfo.FileName),
),
)
upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodGet, registryURL, nil)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unable to request package"})
return
}
upstreamRequest.SetBasicAuth(s.cfg.GiteaPackageUser, s.cfg.GiteaPackageToken)
upstreamResponse, err := s.packageClient.Do(upstreamRequest)
if err != nil {
_ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), err.Error())
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry is unavailable"})
return
}
defer upstreamResponse.Body.Close()
if upstreamResponse.StatusCode != http.StatusOK {
_ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), upstreamResponse.Status)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry rejected the request"})
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", packageInfo.FileName))
w.Header().Set("X-TAPM-SHA256", packageInfo.SHA256)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, upstreamResponse.Body); err != nil {
_ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), err.Error())
return
}
_ = s.audit(r.Context(), "package_downloaded", "", &authorizationID, hostname, slug, s.clientIP(r), "")
}
+67
View File
@@ -0,0 +1,67 @@
package app
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
)
var codeEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").
WithPadding(base32.NoPadding)
func randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return nil, err
}
return value, nil
}
func randomToken(size int) (string, error) {
value, err := randomBytes(size)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func deploymentCode() (string, error) {
value, err := randomBytes(7)
if err != nil {
return "", err
}
encoded := codeEncoding.EncodeToString(value)
if len(encoded) < 10 {
return "", errors.New("generated deployment code is too short")
}
return "TAPM-" + encoded[:5] + "-" + encoded[5:10], nil
}
func normalizeCode(value string) string {
return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
}
func hashValue(value string) [32]byte {
return sha256.Sum256([]byte(value))
}
func hashHex(value string) string {
sum := hashValue(value)
return hex.EncodeToString(sum[:])
}
func signValue(secret []byte, value string) string {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func verifySignature(secret []byte, value, signature string) bool {
expected := signValue(secret, value)
return hmac.Equal([]byte(expected), []byte(signature))
}
+30
View File
@@ -0,0 +1,30 @@
package app
import (
"strings"
"testing"
)
func TestDeploymentCodeFormatAndNormalization(t *testing.T) {
code, err := deploymentCode()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(code, "TAPM-") || len(code) != 16 {
t.Fatalf("unexpected code format: %q", code)
}
if got := normalizeCode(strings.ToLower(code)); got != code {
t.Fatalf("normalizeCode() = %q, want %q", got, code)
}
}
func TestSignature(t *testing.T) {
secret := []byte("01234567890123456789012345678901")
signature := signValue(secret, "state")
if !verifySignature(secret, "state", signature) {
t.Fatal("valid signature was rejected")
}
if verifySignature(secret, "different", signature) {
t.Fatal("invalid signature was accepted")
}
}
+260
View File
@@ -0,0 +1,260 @@
package app
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
//go:embed templates/*.html static/*
var webFiles embed.FS
type Server struct {
cfg Config
db *sql.DB
templates *template.Template
client *http.Client
packageClient *http.Client
}
type technician struct {
Login string
DisplayName string
CSRFToken string
}
type packageRecord struct {
ID uint64
Slug string
DisplayName string
PackageName string
PackageVersion string
FileName string
SHA256 string
Enabled bool
}
type authorizationRecord struct {
ID uint64
CodeHint string
CreatedBy string
CustomerLabel string
HostLimit int
HostCount int
CreatedAt time.Time
ExpiresAt time.Time
RevokedAt sql.NullTime
Packages string
}
type auditRecord struct {
EventType string
Actor string
Hostname string
PackageSlug string
SourceIP string
Details string
CreatedAt time.Time
}
type pageData struct {
Title string
Technician *technician
CSRFToken string
Authorizations []authorizationRecord
AuditEvents []auditRecord
Packages []packageRecord
NewCode string
DefaultHostLimit int
DefaultDuration string
Error string
Notice string
GiteaLoginURL string
}
func New(cfg Config) (*Server, error) {
db, err := sql.Open("mysql", cfg.DatabaseDSN)
if err != nil {
return nil, err
}
db.SetConnMaxLifetime(5 * time.Minute)
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("database: %w", err)
}
templates, err := template.New("").Funcs(template.FuncMap{
"formatTime": func(value time.Time) string {
return value.Local().Format("Jan 2, 2006 3:04 PM")
},
"isActive": func(record authorizationRecord) bool {
return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt)
},
}).ParseFS(webFiles, "templates/*.html")
if err != nil {
_ = db.Close()
return nil, err
}
return &Server{
cfg: cfg,
db: db,
templates: templates,
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
},
packageClient: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
},
}, nil
}
func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", s.handleLive)
mux.HandleFunc("GET /health/ready", s.handleReady)
mux.HandleFunc("GET /auth/login", s.handleLogin)
mux.HandleFunc("GET /auth/callback", s.handleCallback)
mux.HandleFunc("POST /auth/logout", s.handleLogout)
mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal))
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage))
mux.HandleFunc("POST /api/v1/exchange", s.handleExchange)
mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload)
mux.HandleFunc("GET /", s.handleHome)
mux.Handle("GET /static/", http.FileServerFS(webFiles))
return s.securityHeaders(s.requestLog(mux))
}
func (s *Server) handleLive(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "live"})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "database unavailable"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
if tech != nil {
http.Redirect(w, r, "/portal", http.StatusSeeOther)
return
}
s.render(w, "login.html", pageData{
Title: "TAPM Deployment Access",
GiteaLoginURL: "/auth/login",
})
}
func (s *Server) render(w http.ResponseWriter, name string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err)
}
}
func (s *Server) clientIP(r *http.Request) string {
if s.cfg.TrustProxyHeaders {
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
return forwarded
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func (s *Server) requestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s ip=%s duration=%s", r.Method, r.URL.Path, s.clientIP(r), time.Since(started).Round(time.Millisecond))
})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func parseUintPath(r *http.Request, name string) (uint64, error) {
return strconv.ParseUint(r.PathValue(name), 10, 64)
}
func joinURL(base *url.URL, path string) string {
result := *base
result.Path = strings.TrimRight(result.Path, "/") + "/" + strings.TrimLeft(path, "/")
return result.String()
}
func copyResponse(w http.ResponseWriter, response *http.Response) {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Disposition"} {
if value := response.Header.Get(header); value != "" {
w.Header().Set(header, value)
}
}
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}
+256
View File
@@ -0,0 +1,256 @@
:root {
color-scheme: dark;
--ink: #edf4f2;
--muted: #95a8a3;
--panel: #111d1c;
--panel-2: #172624;
--line: #28413d;
--accent: #58e0ad;
--accent-dark: #143e32;
--amber: #f1bd6c;
--danger: #ff7f75;
--shadow: 0 24px 70px rgba(0, 0, 0, .28);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at 12% 0%, rgba(88, 224, 173, .1), transparent 30rem),
#091211;
}
button, input { font: inherit; }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 68px;
padding: 0 5vw;
border-bottom: 1px solid rgba(88, 224, 173, .16);
background: rgba(9, 18, 17, .88);
backdrop-filter: blur(18px);
}
.wordmark {
margin-right: 12px;
color: var(--accent);
font-weight: 900;
letter-spacing: .14em;
}
.product-name, .operator { color: var(--muted); }
.operator { display: flex; align-items: center; gap: 18px; }
.operator form { margin: 0; }
.page {
width: min(1380px, 92vw);
margin: 0 auto;
padding: 56px 0 90px;
}
.hero {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: 40px;
margin-bottom: 38px;
}
h1, h2, p { margin-top: 0; }
h1 { max-width: 760px; margin-bottom: 14px; font-size: clamp(2.5rem, 6vw, 5.7rem); line-height: .94; letter-spacing: -.055em; }
h2 { margin-bottom: 0; font-size: 1.4rem; letter-spacing: -.02em; }
.hero p:not(.eyebrow) { max-width: 650px; color: var(--muted); font-size: 1.1rem; line-height: 1.65; }
.eyebrow {
margin-bottom: 10px;
color: var(--accent);
font-size: .72rem;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
}
.policy-card {
display: grid;
min-width: 190px;
padding: 22px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
}
.policy-card span { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; }
.policy-card strong { margin-top: 7px; font-size: 1.25rem; }
.content-grid {
display: grid;
grid-template-columns: minmax(330px, .8fr) minmax(460px, 1.2fr);
gap: 24px;
}
.panel {
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(145deg, rgba(23, 38, 36, .9), rgba(14, 25, 24, .96));
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-heading { padding: 26px 28px 20px; border-bottom: 1px solid var(--line); }
.stack { display: grid; gap: 18px; padding: 26px 28px 30px; }
.stack.compact { padding: 0; }
label, legend { color: var(--muted); font-size: .78rem; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; }
input {
width: 100%;
margin-top: 8px;
padding: 12px 13px;
color: var(--ink);
border: 1px solid #35504b;
border-radius: 10px;
outline: none;
background: #0a1514;
}
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(88, 224, 173, .12); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
fieldset { display: grid; gap: 10px; padding: 0; border: 0; }
legend { margin-bottom: 8px; }
.package-choice {
display: flex;
align-items: center;
gap: 12px;
padding: 13px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(8, 18, 17, .55);
text-transform: none;
}
.package-choice input, .toggle input { width: auto; margin: 0; accent-color: var(--accent); }
.package-choice span { display: grid; gap: 3px; }
.package-choice strong { color: var(--ink); font-size: .94rem; }
.package-choice small { color: var(--muted); }
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
padding: 0 18px;
color: #07110e;
border: 0;
border-radius: 11px;
background: var(--accent);
font-weight: 850;
text-decoration: none;
cursor: pointer;
}
.button.secondary { color: var(--ink); border: 1px solid var(--line); background: #1c302d; }
.button.light { background: #e9fff7; }
.button.wide { width: 100%; }
.text-button { padding: 0; color: var(--accent); border: 0; background: transparent; cursor: pointer; }
.text-button.danger { color: var(--danger); }
.authorization-list { display: grid; max-height: 690px; overflow: auto; }
.authorization { padding: 21px 26px; border-bottom: 1px solid var(--line); }
.authorization:last-child { border-bottom: 0; }
.authorization.closed { opacity: .64; }
.authorization-top { display: flex; justify-content: space-between; gap: 20px; }
.authorization-top > div { display: flex; align-items: center; gap: 10px; }
.status {
display: inline-flex;
padding: 4px 8px;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
font-size: .66rem;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
}
.authorization.active .status, .status.enabled { color: var(--accent); border-color: rgba(88, 224, 173, .4); background: rgba(88, 224, 173, .08); }
.code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; }
dl div { min-width: 0; }
dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; }
dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.code-reveal {
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
margin-bottom: 32px;
padding: 24px 28px;
color: #092119;
border-radius: 18px;
background: var(--accent);
box-shadow: 0 20px 70px rgba(88, 224, 173, .2);
}
.code-reveal .eyebrow { color: #174e3c; }
.code-reveal h2 { margin-bottom: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(1.7rem, 4vw, 2.8rem); letter-spacing: .08em; }
.code-reveal p { margin-bottom: 0; }
.notice { margin-bottom: 22px; padding: 12px 16px; color: var(--accent); border: 1px solid rgba(88, 224, 173, .3); border-radius: 10px; background: rgba(88, 224, 173, .07); }
.package-admin { margin-top: 24px; }
.package-layout { display: grid; grid-template-columns: .8fr 1.2fr; gap: 32px; padding: 28px; }
.package-table { border-right: 1px solid var(--line); padding-right: 30px; }
.package-row { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); }
.package-row div { display: grid; gap: 4px; }
.package-row small { color: var(--muted); }
.toggle { display: flex; align-items: center; gap: 9px; }
.empty, .fine-print { color: var(--muted); }
.audit-panel { margin-top: 24px; }
.audit-table { display: grid; max-height: 520px; overflow: auto; }
.audit-row {
display: grid;
grid-template-columns: minmax(190px, .8fr) minmax(190px, 1fr) minmax(220px, 1.2fr);
gap: 22px;
padding: 15px 28px;
border-bottom: 1px solid var(--line);
}
.audit-row:last-child { border-bottom: 0; }
.audit-row > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.audit-row span, .audit-row small { color: var(--muted); overflow-wrap: anywhere; }
.audit-row strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .86rem; }
.audit-row small { font-size: .72rem; }
.audit-empty { padding: 20px 28px; }
.login-page { display: grid; place-items: center; }
.login-shell { width: min(510px, 92vw); }
.login-card {
padding: clamp(30px, 7vw, 58px);
border: 1px solid var(--line);
border-radius: 28px;
background: linear-gradient(145deg, rgba(23, 38, 36, .96), rgba(10, 20, 19, .98));
box-shadow: var(--shadow);
}
.brand-mark { margin-bottom: 42px; color: var(--accent); font-size: 1.1rem; font-weight: 950; letter-spacing: .18em; }
.login-card h1 { font-size: clamp(2.8rem, 10vw, 4.8rem); }
.lede { margin-bottom: 32px; color: var(--muted); line-height: 1.7; }
.fine-print { margin: 22px 0 0; font-size: .76rem; text-align: center; }
@media (max-width: 900px) {
.hero, .content-grid, .package-layout { grid-template-columns: 1fr; }
.audit-row { grid-template-columns: 1fr 1fr; }
.audit-row > div:last-child { grid-column: 1 / -1; }
.policy-card { width: 100%; }
.package-table { border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 24px; }
}
@media (max-width: 580px) {
.topbar { padding: 0 4vw; }
.product-name, .operator > span { display: none; }
.page { width: 94vw; padding-top: 34px; }
.field-row, dl { grid-template-columns: 1fr; }
.code-reveal { align-items: stretch; flex-direction: column; }
}
+16
View File
@@ -0,0 +1,16 @@
document.addEventListener("click", async (event) => {
const button = event.target.closest("[data-copy]");
if (!button) return;
const target = document.querySelector(button.dataset.copy);
if (!target) return;
try {
await navigator.clipboard.writeText(target.textContent.trim());
const original = button.textContent;
button.textContent = "Copied";
window.setTimeout(() => {
button.textContent = original;
}, 1500);
} catch {
button.textContent = "Copy failed";
}
});
+26
View File
@@ -0,0 +1,26 @@
{{define "login.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-page">
<main class="login-shell">
<section class="login-card">
<div class="brand-mark">TAPM</div>
<p class="eyebrow">Technician authorization</p>
<h1>Deployment Access</h1>
<p class="lede">
Create time-limited access for protected packages without placing
permanent credentials on customer infrastructure.
</p>
<a class="button primary wide" href="{{.GiteaLoginURL}}">Continue with Gitea</a>
<p class="fine-print">Authorized TAI technicians only. Activity is audited.</p>
</section>
</main>
</body>
</html>
{{end}}
+213
View File
@@ -0,0 +1,213 @@
{{define "portal.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · TAPM</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<header class="topbar">
<div>
<span class="wordmark">TAPM</span>
<span class="product-name">Deployment Access</span>
</div>
<div class="operator">
<span>{{.Technician.DisplayName}}</span>
<form action="/auth/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="text-button" type="submit">Sign out</button>
</form>
</div>
</header>
<main class="page">
{{if .NewCode}}
<section class="code-reveal" aria-live="polite">
<div>
<p class="eyebrow">Deployment authorization created</p>
<h2 id="new-code">{{.NewCode}}</h2>
<p>Share this code only with the technician performing the deployment.</p>
</div>
<button class="button light" type="button" data-copy="#new-code">Copy code</button>
</section>
{{end}}
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
<section class="hero">
<div>
<p class="eyebrow">Controlled package delivery</p>
<h1>Open a deployment window.</h1>
<p>
Authorize a limited number of hosts for selected packages. Access
closes automatically at the end of the window.
</p>
</div>
<div class="policy-card">
<span>Default policy</span>
<strong>{{.DefaultHostLimit}} hosts</strong>
<strong>{{.DefaultDuration}} hours</strong>
</div>
</section>
<div class="content-grid">
<section class="panel create-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">New authorization</p>
<h2>Create deployment code</h2>
</div>
</div>
<form action="/portal/authorizations" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Customer or deployment label
<input name="customer_label" maxlength="255" placeholder="Example: Acme cluster refresh">
</label>
<div class="field-row">
<label>
Maximum hosts
<input name="host_limit" type="number" min="1" max="25" value="{{.DefaultHostLimit}}" required>
</label>
<label>
Duration in hours
<input name="duration_hours" type="number" min="1" max="24" value="{{.DefaultDuration}}" required>
</label>
</div>
<fieldset>
<legend>Allowed packages</legend>
{{range .Packages}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="package_id" value="{{.ID}}">
<span>
<strong>{{.DisplayName}}</strong>
<small>{{.PackageVersion}}</small>
</span>
</label>
{{end}}
{{else}}
<p class="empty">No packages have been enabled yet.</p>
{{end}}
</fieldset>
<button class="button primary" type="submit">Create 3-hour code</button>
</form>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Recent activity</p>
<h2>Deployment authorizations</h2>
</div>
</div>
<div class="authorization-list">
{{range .Authorizations}}
<article class="authorization {{if isActive .}}active{{else}}closed{{end}}">
<div class="authorization-top">
<div>
<strong>{{if .CustomerLabel}}{{.CustomerLabel}}{{else}}Unlabeled deployment{{end}}</strong>
<span class="status">{{if isActive .}}Active{{else}}Closed{{end}}</span>
</div>
<span class="code-hint">•••••{{.CodeHint}}</span>
</div>
<dl>
<div><dt>Hosts</dt><dd>{{.HostCount}} / {{.HostLimit}}</dd></div>
<div><dt>Expires</dt><dd>{{formatTime .ExpiresAt}}</dd></div>
<div><dt>Packages</dt><dd>{{.Packages}}</dd></div>
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl>
{{if isActive .}}
<form action="/portal/authorizations/{{.ID}}/revoke" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="text-button danger" type="submit">Revoke now</button>
</form>
{{end}}
</article>
{{else}}
<p class="empty">No deployment authorizations have been created.</p>
{{end}}
</div>
</section>
</div>
<section class="panel package-admin">
<div class="panel-heading">
<div>
<p class="eyebrow">Registry catalog</p>
<h2>Protected packages</h2>
</div>
</div>
<div class="package-layout">
<div class="package-table">
{{range .Packages}}
<div class="package-row">
<div>
<strong>{{.DisplayName}}</strong>
<small>{{.Slug}} · {{.PackageVersion}}</small>
</div>
<span class="status {{if .Enabled}}enabled{{end}}">{{if .Enabled}}Enabled{{else}}Disabled{{end}}</span>
</div>
{{else}}
<p class="empty">Add the first package after publishing it to Gitea.</p>
{{end}}
</div>
<form action="/portal/packages" method="post" class="stack compact">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<div class="field-row">
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label>
<label>Version <input name="package_version" placeholder="26.1.1.31" required></label>
</div>
<label>Filename <input name="file_name" placeholder="SentinelAgent_linux_x86_64.deb" required></label>
<label>SHA-256 <input name="sha256" minlength="64" maxlength="64" placeholder="64 hexadecimal characters" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label>
<button class="button secondary" type="submit">Save package metadata</button>
</form>
</div>
</section>
<section class="panel audit-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Audit trail</p>
<h2>Latest security events</h2>
</div>
</div>
<div class="audit-table">
{{range .AuditEvents}}
<div class="audit-row">
<div>
<strong>{{.EventType}}</strong>
<small>{{formatTime .CreatedAt}}</small>
</div>
<div>
{{if .Actor}}<span>{{.Actor}}</span>{{end}}
{{if .Hostname}}<span>{{.Hostname}}</span>{{end}}
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
</div>
<div>
<span>{{.SourceIP}}</span>
{{if .Details}}<small>{{.Details}}</small>{{end}}
</div>
</div>
{{else}}
<p class="empty audit-empty">No security events have been recorded.</p>
{{end}}
</div>
</section>
</main>
</body>
</html>
{{end}}