chore: auto-commit uncommitted changes
This commit is contained in:
parent
55367fe06e
commit
472e9dc07b
|
|
@ -6,15 +6,15 @@ DATE := $(shell date -u +%Y%m%d-%H%M)
|
||||||
|
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
||||||
|
|
||||||
# Deploy targets
|
# Download hosting
|
||||||
REMOTE := clavitor-hq
|
SITE_HOST := root@clavitor.ai
|
||||||
REMOTE_PATH := /opt/clavitor/bin
|
SITE_DIR := /opt/clavitor-web/downloads
|
||||||
|
|
||||||
export GOFIPS140 := latest
|
export GOFIPS140 := latest
|
||||||
|
|
||||||
# --- Build targets ---
|
# --- Build targets ---
|
||||||
|
|
||||||
.PHONY: build build-all linux-amd64 linux-arm64 release test clean deploy
|
.PHONY: build build-all linux-amd64 linux-arm64 test clean publish
|
||||||
|
|
||||||
build: linux-amd64
|
build: linux-amd64
|
||||||
|
|
||||||
|
|
@ -28,20 +28,16 @@ linux-arm64:
|
||||||
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \
|
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \
|
||||||
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/clavitor
|
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/clavitor
|
||||||
|
|
||||||
release:
|
|
||||||
./scripts/release.sh
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(GO) test ./...
|
$(GO) test ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
|
rm -f $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
|
||||||
|
|
||||||
# --- Deploy ---
|
# --- Publish binaries to clavitor.ai/download ---
|
||||||
|
|
||||||
deploy: linux-amd64
|
publish: build-all
|
||||||
scp $(BINARY)-linux-amd64 $(REMOTE):/tmp/$(BINARY)-new
|
ssh $(SITE_HOST) 'mkdir -p $(SITE_DIR)'
|
||||||
ssh $(REMOTE) 'sudo systemctl stop clavitor && mv /tmp/$(BINARY)-new $(REMOTE_PATH)/$(BINARY) && chmod +x $(REMOTE_PATH)/$(BINARY) && sudo systemctl start clavitor'
|
scp $(BINARY)-linux-amd64 $(SITE_HOST):$(SITE_DIR)/
|
||||||
@echo "Deployed. Verifying..."
|
scp $(BINARY)-linux-arm64 $(SITE_HOST):$(SITE_DIR)/
|
||||||
@sleep 2
|
@echo "Published $(VERSION) to clavitor.ai/download"
|
||||||
@ssh $(REMOTE) 'sudo systemctl is-active clavitor'
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -2,17 +2,28 @@ package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/caddyserver/certmagic"
|
"github.com/caddyserver/certmagic"
|
||||||
"github.com/libdns/cloudflare"
|
"github.com/libdns/cloudflare"
|
||||||
"github.com/mholt/acmez/v3/acme"
|
"github.com/mholt/acmez/v3/acme"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TLSConfig holds configuration for automatic TLS via Let's Encrypt DNS-01.
|
// TLSConfig holds configuration for TLS.
|
||||||
type TLSConfig struct {
|
type TLSConfig struct {
|
||||||
Domain string // e.g. "use1.clavitor.ai"
|
Domain string // e.g. "use1.clavitor.ai"
|
||||||
CFToken string // Cloudflare API token for DNS-01 challenge
|
CFToken string // Cloudflare API token for DNS-01 challenge
|
||||||
|
|
@ -30,15 +41,21 @@ func LoadTLSConfig() TLSConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenAndServeTLS starts an HTTPS server on the given address using
|
// ListenAndServeTLS starts an HTTPS server.
|
||||||
// Let's Encrypt certificates obtained via Cloudflare DNS-01 challenge.
|
//
|
||||||
// Falls back to plain HTTP if TLS is not configured.
|
// Three modes:
|
||||||
|
// - TLS_DOMAIN + CF_API_TOKEN set → Let's Encrypt via Cloudflare DNS-01
|
||||||
|
// - Neither set → self-signed certificate (generated on first run)
|
||||||
|
// - Plain HTTP is never used
|
||||||
func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||||
if cfg.Domain == "" || cfg.CFToken == "" {
|
if cfg.Domain != "" && cfg.CFToken != "" {
|
||||||
log.Printf("TLS not configured (missing TLS_DOMAIN or CF_API_TOKEN), serving plain HTTP")
|
return listenLetsEncrypt(addr, handler, cfg)
|
||||||
return http.ListenAndServe(addr, handler)
|
|
||||||
}
|
}
|
||||||
|
return listenSelfSigned(addr, handler, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenLetsEncrypt uses certmagic for automatic Let's Encrypt certificates.
|
||||||
|
func listenLetsEncrypt(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||||
if cfg.DataDir == "" {
|
if cfg.DataDir == "" {
|
||||||
cfg.DataDir = "/opt/clavitor/certs"
|
cfg.DataDir = "/opt/clavitor/certs"
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +63,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||||
cfg.Email = "ops@clavitor.ai"
|
cfg.Email = "ops@clavitor.ai"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure certmagic
|
|
||||||
certmagic.DefaultACME.Agreed = true
|
certmagic.DefaultACME.Agreed = true
|
||||||
certmagic.DefaultACME.Email = cfg.Email
|
certmagic.DefaultACME.Email = cfg.Email
|
||||||
certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{
|
certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{
|
||||||
|
|
@ -60,7 +76,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||||
magic := certmagic.NewDefault()
|
magic := certmagic.NewDefault()
|
||||||
magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir}
|
magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir}
|
||||||
|
|
||||||
// Obtain/renew cert
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil {
|
if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil {
|
||||||
return fmt.Errorf("certmagic manage %s: %w", cfg.Domain, err)
|
return fmt.Errorf("certmagic manage %s: %w", cfg.Domain, err)
|
||||||
|
|
@ -76,10 +91,93 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Clavitor listening on https://0.0.0.0%s (%s)", addr, cfg.Domain)
|
log.Printf("Clavitor listening on https://0.0.0.0%s (%s)", addr, cfg.Domain)
|
||||||
// TLS certs are managed by certmagic, pass empty cert/key paths
|
|
||||||
return server.ListenAndServeTLS("", "")
|
return server.ListenAndServeTLS("", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listenSelfSigned generates a self-signed certificate and serves HTTPS.
|
||||||
|
// Cert is stored in DataDir (or working directory) so it persists across restarts.
|
||||||
|
// The browser will show a certificate warning — the connection is still encrypted.
|
||||||
|
func listenSelfSigned(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||||
|
certDir := cfg.DataDir
|
||||||
|
if certDir == "" {
|
||||||
|
certDir = "."
|
||||||
|
}
|
||||||
|
certPath := filepath.Join(certDir, "clavitor.crt")
|
||||||
|
keyPath := filepath.Join(certDir, "clavitor.key")
|
||||||
|
|
||||||
|
if err := ensureSelfSignedCert(certPath, keyPath); err != nil {
|
||||||
|
return fmt.Errorf("self-signed cert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Clavitor listening on https://0.0.0.0%s (self-signed)", addr)
|
||||||
|
return server.ListenAndServeTLS(certPath, keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSelfSignedCert creates a self-signed TLS certificate if one doesn't exist.
|
||||||
|
// Valid for 10 years, covers localhost, 127.0.0.1, and all local network IPs.
|
||||||
|
func ensureSelfSignedCert(certPath, keyPath string) error {
|
||||||
|
if _, err := os.Stat(certPath); err == nil {
|
||||||
|
if _, err := os.Stat(keyPath); err == nil {
|
||||||
|
return nil // both exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
|
||||||
|
tmpl := x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{Organization: []string{"Clavitor"}, CommonName: "clavitor"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
DNSNames: []string{"localhost"},
|
||||||
|
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all local interface IPs so LAN access works
|
||||||
|
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||||
|
tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write cert and key as PEM
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
|
||||||
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Generated self-signed TLS certificate: %s", certPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ChallengeError wraps ACME errors with actionable context.
|
// ChallengeError wraps ACME errors with actionable context.
|
||||||
func ChallengeError(err error) string {
|
func ChallengeError(err error) string {
|
||||||
if acmeErr, ok := err.(acme.Problem); ok {
|
if acmeErr, ok := err.(acme.Problem); ok {
|
||||||
|
|
@ -87,3 +185,24 @@ func ChallengeError(err error) string {
|
||||||
}
|
}
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SelfSignedTLSConfig returns a tls.Config with a fresh self-signed certificate.
|
||||||
|
// Used by tests that need HTTPS without filesystem cert files.
|
||||||
|
func SelfSignedTLSConfig() *tls.Config {
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
tmpl := x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: "test"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
DNSNames: []string{"localhost"},
|
||||||
|
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||||
|
}
|
||||||
|
certDER, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||||
|
cert := tls.Certificate{
|
||||||
|
Certificate: [][]byte{certDER},
|
||||||
|
PrivateKey: key,
|
||||||
|
}
|
||||||
|
return &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||||
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -147,6 +147,7 @@ code { font-size: 0.875em; }
|
||||||
/* === TABLES === */
|
/* === TABLES === */
|
||||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||||
.data-table th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); padding: 8px 12px; border-bottom: 2px solid var(--border); }
|
.data-table th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); padding: 8px 12px; border-bottom: 2px solid var(--border); }
|
||||||
|
.data-table th:first-child, .data-table td:first-child { padding-left: 0; }
|
||||||
.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
|
.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||||
.data-table tr:last-child td { border-bottom: none; }
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
|
@ -328,6 +329,7 @@ code { font-size: 0.875em; }
|
||||||
}
|
}
|
||||||
.glass-status-live { background: var(--brand-black); color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
.glass-status-live { background: var(--brand-black); color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.glass-status-planned { background: var(--surface); color: var(--muted); }
|
.glass-status-planned { background: var(--surface); color: var(--muted); }
|
||||||
|
.glass-status-outage { background: #dc2626; color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.glass-country { font-size: 0.72rem; color: var(--muted); margin-left: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
.glass-country { font-size: 0.72rem; color: var(--muted); margin-left: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.glass-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; }
|
.glass-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; }
|
||||||
.glass-latency-left { display: flex; flex-direction: column; }
|
.glass-latency-left { display: flex; flex-direction: column; }
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -29,6 +30,7 @@ var static embed.FS
|
||||||
var templates *template.Template
|
var templates *template.Template
|
||||||
var devMode bool
|
var devMode bool
|
||||||
var db *sql.DB
|
var db *sql.DB
|
||||||
|
var processStartTime = time.Now().Unix()
|
||||||
|
|
||||||
var countryNames = map[string]string{
|
var countryNames = map[string]string{
|
||||||
"US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia",
|
"US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia",
|
||||||
|
|
@ -345,9 +347,9 @@ func main() {
|
||||||
t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode)
|
t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode)
|
||||||
|
|
||||||
// Uptime span tracking: extend existing span or create new one
|
// Uptime span tracking: extend existing span or create new one
|
||||||
// During maintenance: always extend, never create new (no false gaps)
|
// Skip span updates if we just restarted — we can't judge gaps yet
|
||||||
// Normal: gap threshold 60s (2x the 30s interval, allows one missed beat)
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
if (now - processStartTime) >= 60 {
|
||||||
var inMaint bool
|
var inMaint bool
|
||||||
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
|
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
|
||||||
var spanID int64
|
var spanID int64
|
||||||
|
|
@ -358,6 +360,7 @@ func main() {
|
||||||
} else if !inMaint {
|
} else if !inMaint {
|
||||||
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
|
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy daily uptime (kept for backwards compat)
|
// Legacy daily uptime (kept for backwards compat)
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
@ -737,10 +740,30 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Outages
|
||||||
|
type Outage struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
StartAt string `json:"start_at"`
|
||||||
|
EndAt string `json:"end_at"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
var outages []Outage
|
||||||
|
oRows, _ := db.Query(`SELECT id, start_at, COALESCE(end_at,''), node_id, status, description FROM outages ORDER BY id DESC`)
|
||||||
|
if oRows != nil {
|
||||||
|
defer oRows.Close()
|
||||||
|
for oRows.Next() {
|
||||||
|
var o Outage
|
||||||
|
oRows.Scan(&o.ID, &o.StartAt, &o.EndAt, &o.NodeID, &o.Status, &o.Description)
|
||||||
|
outages = append(outages, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
overall := "All Systems Operational"
|
overall := "All Systems Operational"
|
||||||
if inMaintenance {
|
if inMaintenance {
|
||||||
overall = "Scheduled Maintenance"
|
overall = "Scheduled Maintenance"
|
||||||
} else if !allOperational {
|
} else if !allOperational || len(outages) > 0 {
|
||||||
overall = "Some Systems Degraded"
|
overall = "Some Systems Degraded"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,6 +776,7 @@ func main() {
|
||||||
"overall": overall,
|
"overall": overall,
|
||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"incidents": incidents,
|
"incidents": incidents,
|
||||||
|
"outages": outages,
|
||||||
"dates": dates,
|
"dates": dates,
|
||||||
"last_heartbeat": lastBeat,
|
"last_heartbeat": lastBeat,
|
||||||
})
|
})
|
||||||
|
|
@ -825,6 +849,23 @@ func main() {
|
||||||
render(w, data)
|
render(w, data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Downloads — direct file serving, no traversal
|
||||||
|
http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/download/")
|
||||||
|
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") || strings.HasPrefix(name, ".") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join("downloads", name)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
})
|
||||||
|
|
||||||
// SEO
|
// SEO
|
||||||
http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<a href="/" style="font-family:var(--font-sans);font-weight:700;letter-spacing:0.15em;text-transform:uppercase;font-size:0.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:var(--brand-black);border-radius:2px"></span>clavitor</a>
|
<a href="/" style="font-family:var(--font-sans);font-weight:700;letter-spacing:0.15em;text-transform:uppercase;font-size:0.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:var(--brand-black);border-radius:2px"></span>clavitor</a>
|
||||||
<span style="opacity:0.4">GitHub</span>
|
<span style="opacity:0.4">GitHub</span>
|
||||||
<a href="#">Discord</a>
|
<a href="#">Discord</a>
|
||||||
<a href="#">X</a>
|
<a href="https://x.com/clavitorai">X</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/privacy">Privacy</a>
|
<a href="/privacy">Privacy</a>
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@
|
||||||
|
|
||||||
<div class="section container" style="padding-top:24px">
|
<div class="section container" style="padding-top:24px">
|
||||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius-sm);padding:16px 20px;margin-bottom:24px;font-size:0.85rem;color:#854d0e">
|
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius-sm);padding:16px 20px;margin-bottom:24px;font-size:0.85rem;color:#854d0e">
|
||||||
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Expanding our network</strong><br>
|
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Dubai (me-central-1)</strong><br>
|
||||||
We are actively deploying new points of presence. Some planned nodes may show as unavailable during provisioning. Dubai (me-central-1) is temporarily offline due to AWS infrastructure damage in the UAE.
|
The AWS UAE region remains offline following drone strikes on March 1, 2026. No customer data was affected. We are awaiting updates from AWS on restoration, expected in the coming weeks.
|
||||||
</div>
|
</div>
|
||||||
<div class="glass-grid">
|
<div class="glass-grid">
|
||||||
{{range .Pops}}
|
{{range .Pops}}
|
||||||
<div class="glass-pop {{if eq .Status "live"}}glass-live{{else}}glass-planned{{end}}">
|
<div class="glass-pop {{if eq .Status "live"}}glass-live{{else}}glass-planned{{end}}">
|
||||||
<div class="glass-header">
|
<div class="glass-header">
|
||||||
<div><div class="pop-city">{{.City}}</div><div class="pop-country">{{.CountryFull}}</div></div>
|
<div><div class="pop-city">{{.City}}</div><div class="pop-country">{{.CountryFull}}</div></div>
|
||||||
<span class="glass-status {{if eq .Status "live"}}glass-status-live{{else}}glass-status-planned{{end}}">{{.Status}}</span>
|
<span class="glass-status {{if eq .Status "live"}}glass-status-live{{else if eq .Status "outage"}}glass-status-outage{{else}}glass-status-planned{{end}}">{{.Status}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{if eq .Status "live"}}<div class="glass-latency-block">
|
{{if eq .Status "live"}}<div class="glass-latency-block">
|
||||||
<div class="glass-latency-left"><span class="glass-latency-title">Response time</span><span class="glass-latency-hint">lower is better</span></div>
|
<div class="glass-latency-left"><span class="glass-latency-title">Response time</span><span class="glass-latency-hint">lower is better</span></div>
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,10 @@
|
||||||
const nodes = data.nodes || [];
|
const nodes = data.nodes || [];
|
||||||
const dates = data.dates || [];
|
const dates = data.dates || [];
|
||||||
let html = '';
|
let html = '';
|
||||||
// Separate live and planned
|
// Separate live, planned, and outage
|
||||||
const live = nodes.filter(n => n.status === 'live');
|
const live = nodes.filter(n => n.status === 'live');
|
||||||
const planned = nodes.filter(n => n.status !== 'live');
|
const planned = nodes.filter(n => n.status === 'planned');
|
||||||
|
const outageNodes = nodes.filter(n => n.status === 'outage');
|
||||||
|
|
||||||
for (const n of live) {
|
for (const n of live) {
|
||||||
const days = n.uptime_90 || [];
|
const days = n.uptime_90 || [];
|
||||||
|
|
@ -103,7 +104,7 @@
|
||||||
|
|
||||||
html += `<div class="st-node">
|
html += `<div class="st-node">
|
||||||
<div class="st-header">
|
<div class="st-header">
|
||||||
<div><span class="pop-city">${n.city}</span><span class="pop-country">${n.country}</span></div>
|
<div><span class="pop-city">${n.city}</span> <span class="pop-country">${n.country}</span></div>
|
||||||
<span class="st-health ${healthClass}">${healthLabel}</span>
|
<span class="st-health ${healthClass}">${healthLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="st-bars">`;
|
<div class="st-bars">`;
|
||||||
|
|
@ -126,30 +127,42 @@
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outageNodes.length > 0) {
|
||||||
|
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Awaiting Recovery</div>`;
|
||||||
|
for (const n of outageNodes) {
|
||||||
|
html += `<div class="st-node" style="opacity:0.6"><div class="st-header"><div><span class="pop-city">${n.city}</span> <span class="pop-country">${n.country}</span></div><span class="st-health st-health-down">Outage</span></div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (planned.length > 0) {
|
if (planned.length > 0) {
|
||||||
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Planned</div>`;
|
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Planned</div>`;
|
||||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">`;
|
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">`;
|
||||||
for (const n of planned) {
|
for (const n of planned) {
|
||||||
html += `<div style="padding:8px 0"><span class="pop-city" style="color:var(--text-tertiary)">${n.city}</span><span class="pop-country">${n.country}</span></div>`;
|
html += `<div style="padding:8px 0"><span class="pop-city" style="color:var(--text-tertiary)">${n.city}</span> <span class="pop-country">${n.country}</span></div>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
document.getElementById('status-nodes').innerHTML = html;
|
document.getElementById('status-nodes').innerHTML = html;
|
||||||
|
|
||||||
// Incidents
|
// Outages
|
||||||
const incidents = data.incidents || [];
|
const outages = data.outages || [];
|
||||||
if (incidents.length > 0) {
|
if (outages.length > 0) {
|
||||||
let ihtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Recent Incidents</h3>';
|
let ohtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Incidents</h3>';
|
||||||
for (const inc of incidents) {
|
ohtml += '<table class="data-table"><thead><tr><th>Start</th><th>End</th><th>Node</th><th>Status</th><th>Description</th></tr></thead><tbody>';
|
||||||
ihtml += `<div class="st-incident">
|
for (const o of outages) {
|
||||||
<div class="st-incident-title">${inc.title}</div>
|
const statusColor = o.status === 'resolved' ? '#16a34a' : o.status === 'ongoing' ? '#dc2626' : '#ca8a04';
|
||||||
<div class="st-incident-meta">${inc.date} · ${inc.status}</div>
|
ohtml += `<tr>
|
||||||
</div>`;
|
<td style="white-space:nowrap">${o.start_at}</td>
|
||||||
|
<td style="white-space:nowrap">${o.end_at || '—'}</td>
|
||||||
|
<td>${o.node_id}</td>
|
||||||
|
<td><span style="color:${statusColor};font-weight:600;font-size:0.8rem;text-transform:uppercase">${o.status}</span></td>
|
||||||
|
<td style="color:var(--text-secondary)">${o.description}</td>
|
||||||
|
</tr>`;
|
||||||
}
|
}
|
||||||
document.getElementById('status-incidents').innerHTML = ihtml;
|
ohtml += '</tbody></table>';
|
||||||
|
document.getElementById('status-incidents').innerHTML = ohtml;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('status-incidents').innerHTML =
|
document.getElementById('status-incidents').innerHTML =
|
||||||
'<p class="text-sm text-tertiary" style="margin-top:32px">No recent incidents.</p>';
|
'<p class="text-sm text-tertiary" style="margin-top:32px">No incidents.</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('status-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
document.getElementById('status-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue