chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-03-29 06:01:26 -04:00
parent 55367fe06e
commit 472e9dc07b
13 changed files with 227 additions and 56 deletions

View File

@ -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)
# Deploy targets
REMOTE := clavitor-hq
REMOTE_PATH := /opt/clavitor/bin
# Download hosting
SITE_HOST := root@clavitor.ai
SITE_DIR := /opt/clavitor-web/downloads
export GOFIPS140 := latest
# --- 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
@ -28,20 +28,16 @@ linux-arm64:
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/clavitor
release:
./scripts/release.sh
test:
$(GO) test ./...
clean:
rm -f $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
# --- Deploy ---
# --- Publish binaries to clavitor.ai/download ---
deploy: linux-amd64
scp $(BINARY)-linux-amd64 $(REMOTE):/tmp/$(BINARY)-new
ssh $(REMOTE) 'sudo systemctl stop clavitor && mv /tmp/$(BINARY)-new $(REMOTE_PATH)/$(BINARY) && chmod +x $(REMOTE_PATH)/$(BINARY) && sudo systemctl start clavitor'
@echo "Deployed. Verifying..."
@sleep 2
@ssh $(REMOTE) 'sudo systemctl is-active clavitor'
publish: build-all
ssh $(SITE_HOST) 'mkdir -p $(SITE_DIR)'
scp $(BINARY)-linux-amd64 $(SITE_HOST):$(SITE_DIR)/
scp $(BINARY)-linux-arm64 $(SITE_HOST):$(SITE_DIR)/
@echo "Published $(VERSION) to clavitor.ai/download"

View File

@ -2,19 +2,30 @@ package lib
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/caddyserver/certmagic"
"github.com/libdns/cloudflare"
"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 {
Domain string // e.g. "use1.clavitor.ai"
Domain string // e.g. "use1.clavitor.ai"
CFToken string // Cloudflare API token for DNS-01 challenge
DataDir string // directory to store certs
Email string // ACME account email
@ -30,15 +41,21 @@ func LoadTLSConfig() TLSConfig {
}
}
// ListenAndServeTLS starts an HTTPS server on the given address using
// Let's Encrypt certificates obtained via Cloudflare DNS-01 challenge.
// Falls back to plain HTTP if TLS is not configured.
// ListenAndServeTLS starts an HTTPS server.
//
// 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 {
if cfg.Domain == "" || cfg.CFToken == "" {
log.Printf("TLS not configured (missing TLS_DOMAIN or CF_API_TOKEN), serving plain HTTP")
return http.ListenAndServe(addr, handler)
if cfg.Domain != "" && cfg.CFToken != "" {
return listenLetsEncrypt(addr, handler, cfg)
}
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 == "" {
cfg.DataDir = "/opt/clavitor/certs"
}
@ -46,7 +63,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
cfg.Email = "ops@clavitor.ai"
}
// Configure certmagic
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = cfg.Email
certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{
@ -60,7 +76,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
magic := certmagic.NewDefault()
magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir}
// Obtain/renew cert
ctx := context.Background()
if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil {
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)
// TLS certs are managed by certmagic, pass empty cert/key paths
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.
func ChallengeError(err error) string {
if acmeErr, ok := err.(acme.Problem); ok {
@ -87,3 +185,24 @@ func ChallengeError(err error) string {
}
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}}
}

View File

@ -147,6 +147,7 @@ code { font-size: 0.875em; }
/* === TABLES === */
.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: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 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-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-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; }
.glass-latency-left { display: flex; flex-direction: column; }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,6 +13,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
@ -29,6 +30,7 @@ var static embed.FS
var templates *template.Template
var devMode bool
var db *sql.DB
var processStartTime = time.Now().Unix()
var countryNames = map[string]string{
"US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia",
@ -345,18 +347,19 @@ 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)
// Uptime span tracking: extend existing span or create new one
// During maintenance: always extend, never create new (no false gaps)
// Normal: gap threshold 60s (2x the 30s interval, allows one missed beat)
// Skip span updates if we just restarted — we can't judge gaps yet
now := time.Now().Unix()
var inMaint bool
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
var spanID int64
var spanEnd int64
err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd)
if err == nil && (inMaint || (now-spanEnd) <= 60) {
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} else if !inMaint {
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
if (now - processStartTime) >= 60 {
var inMaint bool
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
var spanID int64
var spanEnd int64
err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd)
if err == nil && (inMaint || (now-spanEnd) <= 60) {
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} else if !inMaint {
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
}
}
// Legacy daily uptime (kept for backwards compat)
@ -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"
if inMaintenance {
overall = "Scheduled Maintenance"
} else if !allOperational {
} else if !allOperational || len(outages) > 0 {
overall = "Some Systems Degraded"
}
@ -753,6 +776,7 @@ func main() {
"overall": overall,
"nodes": nodes,
"incidents": incidents,
"outages": outages,
"dates": dates,
"last_heartbeat": lastBeat,
})
@ -825,6 +849,23 @@ func main() {
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
http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")

View File

@ -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>
<span style="opacity:0.4">GitHub</span>
<a href="#">Discord</a>
<a href="#">X</a>
<a href="https://x.com/clavitorai">X</a>
</div>
<div class="footer-links">
<a href="/privacy">Privacy</a>

View File

@ -7,15 +7,15 @@
<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">
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Expanding our network</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.
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Dubai (me-central-1)</strong><br>
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 class="glass-grid">
{{range .Pops}}
<div class="glass-pop {{if eq .Status "live"}}glass-live{{else}}glass-planned{{end}}">
<div class="glass-header">
<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>
{{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>

View File

@ -87,9 +87,10 @@
const nodes = data.nodes || [];
const dates = data.dates || [];
let html = '';
// Separate live and planned
// Separate live, planned, and outage
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) {
const days = n.uptime_90 || [];
@ -103,7 +104,7 @@
html += `<div class="st-node">
<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>
</div>
<div class="st-bars">`;
@ -126,30 +127,42 @@
</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) {
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">`;
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>`;
}
document.getElementById('status-nodes').innerHTML = html;
// Incidents
const incidents = data.incidents || [];
if (incidents.length > 0) {
let ihtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Recent Incidents</h3>';
for (const inc of incidents) {
ihtml += `<div class="st-incident">
<div class="st-incident-title">${inc.title}</div>
<div class="st-incident-meta">${inc.date} &middot; ${inc.status}</div>
</div>`;
// Outages
const outages = data.outages || [];
if (outages.length > 0) {
let ohtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Incidents</h3>';
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>';
for (const o of outages) {
const statusColor = o.status === 'resolved' ? '#16a34a' : o.status === 'ongoing' ? '#dc2626' : '#ca8a04';
ohtml += `<tr>
<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 {
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();