clavitor/clavitor.ai/main.go

1350 lines
46 KiB
Go

package main
import (
"crypto/sha256"
"database/sql"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/smtp"
"html/template"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed templates/*.tmpl
var tmplFS embed.FS
//go:embed *.svg *.css *.png
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",
"BR": "Brazil", "CL": "Chile", "GB": "United Kingdom", "CH": "Switzerland",
"ES": "Spain", "SE": "Sweden", "TR": "Turkey", "AE": "UAE",
"NG": "Nigeria", "KE": "Kenya", "ZA": "South Africa", "IN": "India",
"SG": "Singapore", "AU": "Australia", "JP": "Japan", "KR": "South Korea",
"HK": "Hong Kong", "NZ": "New Zealand", "KZ": "Kazakhstan", "BD": "Bangladesh",
"PH": "Philippines", "TH": "Thailand", "TW": "Taiwan", "ID": "Indonesia",
}
func countryName(code string) string {
if name, ok := countryNames[code]; ok {
return name
}
return code
}
func fmtInt(n int) string {
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var result []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
return string(result)
}
type Pop struct {
PopID int
City string
Country string
Lat float64
Lon float64
RegionName string
IP string
DNS string
Status string
Provider string
CountryFull string
BackupCity string
BackupDistanceKM int
BackupDistFmt string
BackupDistMiFmt string
}
type PageData struct {
Page string
Title string
Desc string
ActiveNav string
Pops []Pop
PopsByCity []Pop
}
func loadTemplates() {
if devMode {
templates = template.Must(template.ParseGlob("templates/*.tmpl"))
} else {
sub, _ := fs.Sub(tmplFS, "templates")
templates = template.Must(template.ParseFS(sub, "*.tmpl"))
}
}
func loadPops() []Pop {
rows, err := db.Query("SELECT pop_id, city, country, lat, lon, region_name, ip, dns, status, provider, backup_city, backup_distance_km FROM pops ORDER BY CASE status WHEN 'live' THEN 0 ELSE 1 END, lon DESC")
if err != nil {
log.Printf("pops query error: %v", err)
return nil
}
defer rows.Close()
var pops []Pop
for rows.Next() {
var p Pop
if err := rows.Scan(&p.PopID, &p.City, &p.Country, &p.Lat, &p.Lon, &p.RegionName, &p.IP, &p.DNS, &p.Status, &p.Provider, &p.BackupCity, &p.BackupDistanceKM); err != nil {
log.Printf("pops scan error: %v", err)
continue
}
p.CountryFull = countryName(p.Country)
p.BackupDistFmt = fmtInt(p.BackupDistanceKM)
p.BackupDistMiFmt = fmtInt(int(float64(p.BackupDistanceKM) * 0.621371))
pops = append(pops, p)
}
return pops
}
func render(w http.ResponseWriter, data PageData) {
if devMode {
loadTemplates()
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "Internal error", 500)
}
}
func geoHandler(w http.ResponseWriter, r *http.Request) {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
if i := strings.LastIndex(ip, ":"); i >= 0 {
ip = ip[:i]
}
ip = strings.Trim(ip, "[]")
resp, err := http.Get("https://ipapi.co/" + ip + "/json/")
if err != nil {
http.Error(w, `{"error":"geo failed"}`, 502)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
io.Copy(w, resp.Body)
}
// classifyDomain asks an LLM to classify a domain into a scope category.
// Called once per unknown domain. Result cached in SQLite forever.
func classifyDomain(domain string) string {
d := strings.ToLower(strings.TrimSpace(domain))
// Local IPs — no LLM needed
if strings.HasPrefix(d, "192.168.") || strings.HasPrefix(d, "10.") ||
strings.HasPrefix(d, "172.16.") || strings.HasPrefix(d, "172.17.") ||
strings.HasPrefix(d, "172.18.") || strings.HasPrefix(d, "172.19.") ||
strings.HasPrefix(d, "172.2") || strings.HasPrefix(d, "172.3") ||
d == "localhost" || d == "127.0.0.1" {
return "home"
}
// Ask LLM
scope := classifyViaLLM(d)
if scope != "" {
return scope
}
return "misc"
}
// classifyBatch classifies all domains in one LLM call.
// Returns map[domain]scope. Domains that fail get "misc".
func classifyBatch(domains []string) map[string]string {
result := make(map[string]string)
// Handle local IPs without LLM
var toLLM []string
for _, d := range domains {
if strings.HasPrefix(d, "192.168.") || strings.HasPrefix(d, "10.") ||
strings.HasPrefix(d, "172.") || d == "localhost" || d == "127.0.0.1" {
result[d] = "home"
} else {
toLLM = append(toLLM, d)
}
}
if len(toLLM) == 0 {
return result
}
// Build domain list for prompt
domainList := strings.Join(toLLM, "\n")
prompt := fmt.Sprintf(
`Classify each domain into exactly ONE category. Reply with ONLY lines in format: domain=category
No explanations. One line per domain. Pick the single MOST specific category.
Categories: finance, social, shopping, work, dev, email, media, health, travel, home, education, government
Domains:
%s`, domainList)
reqBody, _ := json.Marshal(map[string]any{
"model": "anthropic/claude-haiku-4-5",
"messages": []map[string]string{
{"role": "system", "content": "You classify internet domains. Reply ONLY with lines in format: domain=category. No explanations, no reasoning, no markdown."},
{"role": "user", "content": prompt},
},
"max_tokens": 4096,
"temperature": 0,
})
apiKey := os.Getenv("OPENROUTER_API_KEY")
if apiKey == "" {
log.Printf("classifyBatch: OPENROUTER_API_KEY not set")
return result
}
req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", strings.NewReader(string(reqBody)))
if err != nil {
return result
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("classifyBatch: LLM call error: %v", err)
return result
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var llmResult struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &llmResult); err != nil || len(llmResult.Choices) == 0 {
snippet := string(body)
if len(snippet) > 300 { snippet = snippet[:300] }
log.Printf("classifyBatch: LLM error (status=%d): %s", resp.StatusCode, snippet)
return result
}
// Parse response lines: "domain=scope1,scope2"
content := llmResult.Choices[0].Message.Content
log.Printf("classifyBatch: raw LLM response (%d chars): %s", len(content), content)
// Strip thinking tags
if idx := strings.Index(content, "</think>"); idx >= 0 {
content = content[idx+8:]
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
// Try multiple separators: =, :, -
var domain, scopes string
for _, sep := range []string{"=", ": ", " - "} {
parts := strings.SplitN(line, sep, 2)
if len(parts) == 2 {
domain = strings.TrimSpace(parts[0])
scopes = strings.TrimSpace(parts[1])
break
}
}
if domain == "" || scopes == "" {
continue
}
// Strip backticks, quotes, markdown
domain = strings.Trim(domain, "`\"'*- ")
scopes = strings.Trim(scopes, "`\"'*- ")
// Validate each scope
var valid []string
for _, s := range strings.Split(scopes, ",") {
s = strings.TrimSpace(s)
if validScopes[s] {
valid = append(valid, s)
}
}
if len(valid) > 0 {
result[domain] = strings.Join(valid, ",")
}
}
log.Printf("classifyBatch: classified %d/%d domains", len(result), len(domains))
return result
}
var validScopes = map[string]bool{
"finance": true, "social": true, "shopping": true, "work": true,
"dev": true, "email": true, "media": true, "health": true,
"travel": true, "home": true, "education": true, "government": true,
"misc": true,
}
func classifyViaLLM(domain string) string {
prompt := fmt.Sprintf(
`Classify this internet domain into one or more categories. Reply with ONLY the category names, comma-separated, nothing else. Pick the most specific ones. Use "misc" only if nothing else fits.
Categories: finance, social, shopping, work, dev, email, media, health, travel, home, education, government
Domain: %s`, domain)
reqBody, _ := json.Marshal(map[string]any{
"model": "anthropic/claude-haiku-4-5",
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"max_tokens": 200,
"temperature": 0,
})
req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", strings.NewReader(string(reqBody)))
if err != nil {
log.Printf("classify LLM req error: %v", err)
return ""
}
req.Header.Set("Content-Type", "application/json")
apiKey := os.Getenv("OPENROUTER_API_KEY")
if apiKey == "" {
log.Printf("classify: OPENROUTER_API_KEY not set")
return ""
}
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("classify LLM call error: %v", err)
return ""
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &result); err != nil || len(result.Choices) == 0 {
snippet := string(body)
if len(snippet) > 500 { snippet = snippet[:500] }
log.Printf("classify LLM error (status=%d): %s", resp.StatusCode, snippet)
return ""
}
raw := strings.ToLower(strings.TrimSpace(result.Choices[0].Message.Content))
// Strip thinking tags
if idx := strings.Index(raw, "</think>"); idx >= 0 {
raw = strings.TrimSpace(raw[idx+8:])
}
// Validate each scope
var valid []string
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if validScopes[s] {
valid = append(valid, s)
}
}
if len(valid) == 0 {
log.Printf("classify: %s → invalid response %q, defaulting to misc", domain, raw)
return "misc"
}
scope := strings.Join(valid, ",")
log.Printf("classify: %s → %s", domain, scope)
return scope
}
func main() {
if _, err := os.Stat("templates"); err == nil {
devMode = true
log.Println("dev mode: templates loaded from disk")
}
loadTemplates()
var err error
db, err = sql.Open("sqlite3", "clavitor.db")
if err != nil {
log.Fatalf("failed to open clavitor.db: %v", err)
}
defer db.Close()
// Migrations
db.Exec(`ALTER TABLE pops ADD COLUMN backup_city TEXT DEFAULT ''`)
db.Exec(`ALTER TABLE pops ADD COLUMN backup_distance_km INTEGER DEFAULT 0`)
db.Exec(`CREATE TABLE IF NOT EXISTS domain_scopes (domain TEXT PRIMARY KEY, scope TEXT NOT NULL, created_at INTEGER NOT NULL)`)
port := os.Getenv("PORT")
if port == "" {
port = "8099"
}
// Domain classification — stateless, no logging, cached
http.HandleFunc("/classify", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
if r.Method != "POST" {
http.Error(w, `{"error":"POST required"}`, 405)
return
}
var domains []string
if err := json.NewDecoder(r.Body).Decode(&domains); err != nil || len(domains) == 0 {
http.Error(w, `{"error":"expected JSON array of domains"}`, 400)
return
}
if len(domains) > 5000 {
domains = domains[:5000]
}
result := make(map[string]string)
// Check cache first
var uncached []string
for _, d := range domains {
d = strings.ToLower(strings.TrimSpace(d))
if d == "" {
continue
}
// RFC1918 / local IPs → home
if strings.HasPrefix(d, "192.168.") || strings.HasPrefix(d, "10.") || strings.HasPrefix(d, "172.") || d == "localhost" {
result[d] = "home"
continue
}
var scope string
err := db.QueryRow("SELECT scope FROM domain_scopes WHERE domain = ?", d).Scan(&scope)
if err == nil {
result[d] = scope
} else {
uncached = append(uncached, d)
}
}
// Classify uncached domains in chunks (max 200 per LLM call to stay within output token limits)
if len(uncached) > 0 {
chunkSize := 200
for i := 0; i < len(uncached); i += chunkSize {
end := i + chunkSize
if end > len(uncached) {
end = len(uncached)
}
chunk := uncached[i:end]
batch := classifyBatch(chunk)
for _, d := range chunk {
scope := batch[d]
if scope == "" || scope == "misc" {
result[d] = "misc"
} else {
result[d] = scope
db.Exec("INSERT OR REPLACE INTO domain_scopes (domain, scope, created_at) VALUES (?, ?, ?)",
d, scope, time.Now().Unix())
}
}
}
log.Printf("classify: %d uncached domains in %d chunks, %d cached total",
len(uncached), (len(uncached)+chunkSize-1)/chunkSize, len(result))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
http.HandleFunc("/geo", geoHandler)
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, `{"error":"missing host"}`, 400)
return
}
start := time.Now()
conn, err := net.DialTimeout("tcp", host+":1984", 5*time.Second)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"error":"unreachable"}`))
return
}
conn.Close()
ms := time.Since(start).Milliseconds()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ms":%d}`, ms)
})
http.HandleFunc("/hosted", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "hosted", Title: "clavitor — Hosted", ActiveNav: "hosted"}
data.Pops = loadPops()
sorted := make([]Pop, len(data.Pops))
copy(sorted, data.Pops)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].City < sorted[j].City })
data.PopsByCity = sorted
render(w, data)
})
http.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "install", Title: "Self-host — clavitor", Desc: "Self-host clavitor in 30 seconds. One binary, no dependencies.", ActiveNav: "install"})
})
http.HandleFunc("/pricing", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "pricing", Title: "Pricing — clavitor", Desc: "Free self-hosted or $12/year hosted (launch price). No tiers, no per-seat, no contact sales.", ActiveNav: "pricing"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/pricing-new", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "pricing-new", Title: "Pricing — clavitor", Desc: "From $12/year. Personal, Family, Team, Enterprise. Same vault, same encryption, every tier.", ActiveNav: "pricing"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/install-new", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "install-new", Title: "Self-host Clavitor — clavitor", Desc: "One binary, no Docker, no dependencies. Self-host guide.", ActiveNav: "install"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/for/consumer", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "for-consumer", Title: "Clavitor for Individuals — AI-native credential issuance", Desc: "Your AI agent needs your API keys — not your passport number. Scoped credential issuance for personal AI agents.", ActiveNav: "for-consumer"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/for/smb", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "for-smb", Title: "Clavitor for Teams — credential issuance for AI agents", Desc: "Every employee will have an agent. Each agent needs credentials. Each credential needs boundaries. Scoped credential issuance for teams.", ActiveNav: "for-smb"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/for/mme", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "for-mme", Title: "Clavitor for Mid-Market Enterprise — credential issuance at scale", Desc: "500+ employees, thousands of AI agents. SCIM provisioning, centralized audit, per-user pricing. Credential issuance that scales.", ActiveNav: "for-mme"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/for/enterprise", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "for-enterprise", Title: "Clavitor Enterprise — credential issuance with compliance", Desc: "Your agents are smarter than your policies. Hardware-enforced credential boundaries. SOC 2, ISO 27001, five nines SLA.", ActiveNav: "for-enterprise"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/for/msp", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "for-msp", Title: "Clavitor for MSPs — managed credential issuance", Desc: "Deploy credential issuance across your client base. Client-owned vaults, technician scoping, cross-client audit. Reseller compensation included.", ActiveNav: "for-msp"}
data.Pops = loadPops()
render(w, data)
})
http.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "privacy", Title: "Privacy Policy — clavitor"})
})
http.HandleFunc("/terms", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "terms", Title: "Terms of Service — clavitor"})
})
http.HandleFunc("/sources", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "sources", Title: "Sources — clavitor"})
})
http.HandleFunc("/upgrade", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "upgrade", Title: "Upgrade to Clavitor — clavitor", ActiveNav: "upgrade"})
})
http.HandleFunc("/developers", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "developers", Title: "Developers — clavitor", ActiveNav: "developers"})
})
http.HandleFunc("/integrations/claude-code", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "claude-code", Title: "Clavitor + Claude Code — Secure credential access", ActiveNav: "integrations"})
})
http.HandleFunc("/integrations/codex", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "codex", Title: "Clavitor + OpenAI Codex — CLI integration", ActiveNav: "integrations"})
})
http.HandleFunc("/integrations/openclaw", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "openclaw", Title: "Clavitor + OpenClaw — Multi-agent credentials", ActiveNav: "integrations"})
})
http.HandleFunc("/integrations/openclaw/cn", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "openclaw-cn", Title: "Clavitor + OpenClaw — AI 智能体凭据管理", ActiveNav: "integrations"})
})
// Notify — sends signup interest email
http.HandleFunc("/notify", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.WriteHeader(405)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"error":"invalid email"}`))
return
}
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
if smtpUser == "" || smtpPass == "" {
log.Printf("notify: SMTP not configured, email from %s", req.Email)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
return
}
go func() {
msg := fmt.Sprintf("From: %s\r\nTo: johan@clavitor.ai\r\nSubject: Clavitor signup interest: %s\r\n\r\n%s wants to be notified when signups open.\r\n", smtpUser, req.Email, req.Email)
auth := smtp.PlainAuth("", smtpUser, smtpPass, "smtp.protonmail.ch")
if err := smtp.SendMail("smtp.protonmail.ch:587", auth, smtpUser, []string{"johan@clavitor.ai"}, []byte(msg)); err != nil {
log.Printf("notify: smtp error: %v", err)
} else {
log.Printf("notify: sent for %s", req.Email)
}
}()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
})
http.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "signup", Title: "Sign up — clavitor"})
})
http.HandleFunc("/styleguide", func(w http.ResponseWriter, r *http.Request) {
render(w, PageData{Page: "styleguide", Title: "clavitor — Styleguide"})
})
// NOC telemetry ingest — agents POST here
// Accepts both flat format (node_id, cpu_percent, ...) and nested vault format
// (hostname, system.cpu_percent, vaults.count, ...)
http.HandleFunc("/telemetry", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.WriteHeader(405)
return
}
var t struct {
// Flat fields (legacy/direct)
NodeID string `json:"node_id"`
Version string `json:"version"`
Hostname string `json:"hostname"`
UptimeSeconds int64 `json:"uptime_seconds"`
CPUPercent float64 `json:"cpu_percent"`
MemTotalMB int64 `json:"memory_total_mb"`
MemUsedMB int64 `json:"memory_used_mb"`
DiskTotalMB int64 `json:"disk_total_mb"`
DiskUsedMB int64 `json:"disk_used_mb"`
Load1m float64 `json:"load_1m"`
VaultCount int `json:"vault_count"`
VaultSizeMB float64 `json:"vault_size_mb"`
VaultEntries int `json:"vault_entries"`
Mode string `json:"mode"`
// Nested fields (clovis-vault TelemetryPayload)
System struct {
OS string `json:"os"`
Arch string `json:"arch"`
CPUs int `json:"cpus"`
CPUPercent float64 `json:"cpu_percent"`
MemTotalMB int64 `json:"memory_total_mb"`
MemUsedMB int64 `json:"memory_used_mb"`
DiskTotalMB int64 `json:"disk_total_mb"`
DiskUsedMB int64 `json:"disk_used_mb"`
Load1m float64 `json:"load_1m"`
} `json:"system"`
Vaults struct {
Count int `json:"count"`
TotalSizeMB int64 `json:"total_size_mb"`
TotalEntries int64 `json:"total_entries"`
} `json:"vaults"`
}
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, `{"error":"bad payload"}`, 400)
return
}
// Use hostname as node_id if node_id not provided
if t.NodeID == "" {
t.NodeID = t.Hostname
}
if t.NodeID == "" {
http.Error(w, `{"error":"missing node_id or hostname"}`, 400)
return
}
// Merge nested fields into flat fields if flat is zero
if t.CPUPercent == 0 && t.System.CPUPercent != 0 {
t.CPUPercent = t.System.CPUPercent
}
if t.MemTotalMB == 0 { t.MemTotalMB = t.System.MemTotalMB }
if t.MemUsedMB == 0 { t.MemUsedMB = t.System.MemUsedMB }
if t.DiskTotalMB == 0 { t.DiskTotalMB = t.System.DiskTotalMB }
if t.DiskUsedMB == 0 { t.DiskUsedMB = t.System.DiskUsedMB }
if t.Load1m == 0 { t.Load1m = t.System.Load1m }
if t.VaultCount == 0 { t.VaultCount = int(t.Vaults.Count) }
if t.VaultSizeMB == 0 { t.VaultSizeMB = float64(t.Vaults.TotalSizeMB) }
if t.VaultEntries == 0 { t.VaultEntries = int(t.Vaults.TotalEntries) }
db.Exec(`INSERT INTO telemetry (node_id, version, hostname, uptime_seconds, cpu_percent, memory_total_mb, memory_used_mb, disk_total_mb, disk_used_mb, load_1m, vault_count, vault_size_mb, vault_entries, mode) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
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
now := time.Now().Unix()
serverAge := now - processStartTime
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) {
// Normal: extend existing span
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} else if err == nil && serverAge < 60 {
// Server just started — can't judge gaps yet, extend existing span
log.Printf("SPAN EXTEND node=%s gap=%ds (server up %ds, too early to judge)", t.NodeID, now-spanEnd, serverAge)
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} else if !inMaint {
// Genuine outage or first-ever span
gapSeconds := now - spanEnd
if err == nil {
log.Printf("OUTAGE SPAN node=%s gap=%ds last_seen=%s resumed=%s prev_span_id=%d hostname=%s version=%s cpu=%.1f%% mem=%d/%dMB disk=%d/%dMB load=%.2f uptime=%ds",
t.NodeID, gapSeconds,
time.Unix(spanEnd, 0).UTC().Format(time.RFC3339),
time.Unix(now, 0).UTC().Format(time.RFC3339),
spanID, t.Hostname, t.Version,
t.CPUPercent, t.MemUsedMB, t.MemTotalMB,
t.DiskUsedMB, t.DiskTotalMB, t.Load1m, t.UptimeSeconds)
} else {
log.Printf("OUTAGE SPAN node=%s first_span=true hostname=%s version=%s",
t.NodeID, t.Hostname, t.Version)
}
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
// Alert via ntfy
go func(nodeID, hostname string, gap int64, firstSpan bool) {
title := fmt.Sprintf("Outage recovery: %s", nodeID)
body := fmt.Sprintf("Node **%s** (%s) created new span after **%ds** gap", nodeID, hostname, gap)
if firstSpan {
title = fmt.Sprintf("New node online: %s", nodeID)
body = fmt.Sprintf("Node **%s** (%s) first heartbeat — new span created", nodeID, hostname)
}
req, err := http.NewRequest("POST", "http://127.0.0.1:2586/clavitor-alerts", strings.NewReader(body))
if err != nil {
log.Printf("OUTAGE SPAN ntfy error creating request: %v", err)
return
}
req.Header.Set("Authorization", "Bearer tk_k120jegay3lugeqbr9fmpuxdqmzx5")
req.Header.Set("Title", title)
req.Header.Set("Markdown", "yes")
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("OUTAGE SPAN ntfy error sending alert: %v", err)
return
}
resp.Body.Close()
log.Printf("OUTAGE SPAN ntfy alert sent for node=%s", nodeID)
}(t.NodeID, t.Hostname, gapSeconds, err != nil)
}
// Legacy daily uptime (kept for backwards compat)
today := time.Now().Format("2006-01-02")
db.Exec(`INSERT OR REPLACE INTO uptime (node_id, date, status) VALUES (?, ?, 'operational')`, t.NodeID, today)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
})
// NOC API — latest telemetry per node
nocPin := func(r *http.Request) bool { return r.URL.Query().Get("pin") == "250365" }
http.HandleFunc("/noc/api/telemetry", func(w http.ResponseWriter, r *http.Request) {
if !nocPin(r) { http.NotFound(w, r); return }
rows, err := db.Query(`SELECT t.node_id, t.received_at, t.version, t.hostname, t.uptime_seconds, t.cpu_percent, t.memory_total_mb, t.memory_used_mb, t.disk_total_mb, t.disk_used_mb, t.load_1m, t.vault_count, t.vault_size_mb, t.vault_entries, t.mode FROM telemetry t INNER JOIN (SELECT node_id, MAX(id) as max_id FROM telemetry GROUP BY node_id) latest ON t.id = latest.max_id`)
if err != nil {
http.Error(w, `{"error":"query failed"}`, 500)
return
}
defer rows.Close()
type Tel struct {
NodeID string `json:"node_id"`
ReceivedAt int64 `json:"received_at"`
Version string `json:"version"`
Hostname string `json:"hostname"`
UptimeSec int64 `json:"uptime_seconds"`
CPU float64 `json:"cpu_percent"`
MemTotal int64 `json:"memory_total_mb"`
MemUsed int64 `json:"memory_used_mb"`
DiskTotal int64 `json:"disk_total_mb"`
DiskUsed int64 `json:"disk_used_mb"`
Load1m float64 `json:"load_1m"`
VaultCount int `json:"vault_count"`
VaultSizeMB float64 `json:"vault_size_mb"`
VaultEntries int `json:"vault_entries"`
Mode string `json:"mode"`
}
var list []Tel
for rows.Next() {
var t Tel
rows.Scan(&t.NodeID, &t.ReceivedAt, &t.Version, &t.Hostname, &t.UptimeSec, &t.CPU, &t.MemTotal, &t.MemUsed, &t.DiskTotal, &t.DiskUsed, &t.Load1m, &t.VaultCount, &t.VaultSizeMB, &t.VaultEntries, &t.Mode)
list = append(list, t)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"telemetry": list})
})
http.HandleFunc("/noc/api/nodes", func(w http.ResponseWriter, r *http.Request) {
if !nocPin(r) { http.NotFound(w, r); return }
pops := loadPops()
type N struct {
ID string `json:"ID"`
City string `json:"City"`
Country string `json:"Country"`
Status string `json:"Status"`
}
var nodes []N
for _, p := range pops {
id := p.DNS
if idx := strings.Index(id, "."); idx > 0 {
id = id[:idx] // "use1.clavitor.ai" -> "use1"
}
if id == "" {
id = p.City
}
nodes = append(nodes, N{ID: id, City: p.City, Country: countryName(p.Country), Status: p.Status})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
})
http.HandleFunc("/noc/api/telemetry/history", func(w http.ResponseWriter, r *http.Request) {
if !nocPin(r) { http.NotFound(w, r); return }
node := r.URL.Query().Get("node")
limit := r.URL.Query().Get("limit")
if limit == "" { limit = "60" }
rows, err := db.Query(`SELECT received_at, cpu_percent, memory_used_mb, memory_total_mb FROM telemetry WHERE node_id = ? ORDER BY id DESC LIMIT ?`, node, limit)
if err != nil {
http.Error(w, `{"error":"query failed"}`, 500)
return
}
defer rows.Close()
type H struct {
TS int64 `json:"ts"`
CPU float64 `json:"cpu"`
MemUsed int64 `json:"mem_used_mb"`
MemTotal int64 `json:"mem_total_mb"`
}
var hist []H
for rows.Next() {
var h H
rows.Scan(&h.TS, &h.CPU, &h.MemUsed, &h.MemTotal)
hist = append(hist, h)
}
// Reverse so oldest first
for i, j := 0, len(hist)-1; i < j; i, j = i+1, j-1 {
hist[i], hist[j] = hist[j], hist[i]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"history": hist})
})
// NOC dashboard — hardcoded PIN, read-only, not a security boundary
http.HandleFunc("/noc", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("pin") != "250365" {
http.NotFound(w, r)
return
}
data := PageData{Page: "noc", Title: "NOC — clavitor"}
data.Pops = loadPops()
render(w, data)
})
// --- Uptime rollup helper ---
// Calculates uptime % for a node on a given date from spans + maintenance windows.
// Caches result in uptime_daily. Only recalculates if date is today (ongoing) or not cached.
rollupDay := func(nodeID, date string) float64 {
// Check cache (skip today — always recalculate)
today := time.Now().Format("2006-01-02")
if date != today {
var cached float64
if db.QueryRow(`SELECT uptime_pct FROM uptime_daily WHERE node_id = ? AND date = ?`, nodeID, date).Scan(&cached) == nil {
return cached
}
}
// Parse day boundaries
dayStart, _ := time.Parse("2006-01-02", date)
dayEnd := dayStart.Add(24 * time.Hour)
dsUnix := dayStart.Unix()
deUnix := dayEnd.Unix()
// If day is in the future, return -1 (no data)
if dsUnix > time.Now().Unix() {
return -1
}
// Cap end to now if today
if deUnix > time.Now().Unix() {
deUnix = time.Now().Unix()
}
totalSeconds := deUnix - dsUnix
if totalSeconds <= 0 {
return -1
}
// Find when this node first came online (first span ever)
var firstEver int64
db.QueryRow(`SELECT MIN(start_at) FROM uptime_spans WHERE node_id = ?`, nodeID).Scan(&firstEver)
// If the node didn't exist yet on this day, no data
if firstEver == 0 || firstEver >= deUnix {
return -1
}
// If the node came online partway through this day, start counting from then
if firstEver > dsUnix {
dsUnix = firstEver
totalSeconds = deUnix - dsUnix
if totalSeconds <= 0 {
return -1
}
}
// Sum span overlap with this day
var upSeconds int64
var hasSpans bool
var lastSpanEnd int64
if rows, err := db.Query(`SELECT start_at, end_at FROM uptime_spans WHERE node_id = ? AND end_at > ? AND start_at < ? ORDER BY start_at`,
nodeID, dsUnix, deUnix); err == nil {
for rows.Next() {
hasSpans = true
var s, e int64
rows.Scan(&s, &e)
if s < dsUnix { s = dsUnix }
if e > deUnix { e = deUnix }
if e > s { upSeconds += e - s }
lastSpanEnd = e
}
rows.Close()
}
// If the trailing gap to now is within heartbeat interval, count it as up
if lastSpanEnd > 0 && (deUnix-lastSpanEnd) <= 60 {
upSeconds += deUnix - lastSpanEnd
}
// No spans at all for this day = no data (node didn't exist yet)
if !hasSpans {
return -1
}
// Subtract maintenance windows from denominator
var maintSeconds int64
if mRows, err := db.Query(`SELECT start_at, COALESCE(end_at, ?) FROM maintenance WHERE end_at IS NULL OR (end_at >= ? AND start_at <= ?)`,
deUnix, dsUnix, deUnix); err == nil {
for mRows.Next() {
var s, e int64
mRows.Scan(&s, &e)
if s < dsUnix { s = dsUnix }
if e > deUnix { e = deUnix }
if e > s { maintSeconds += e - s }
}
mRows.Close()
}
effectiveTotal := totalSeconds - maintSeconds
if effectiveTotal <= 0 {
effectiveTotal = 1
upSeconds = 1
}
pct := float64(upSeconds) / float64(effectiveTotal) * 100
if pct > 100 { pct = 100 }
// Cache (don't cache today since it changes)
if date != today {
db.Exec(`INSERT OR REPLACE INTO uptime_daily (node_id, date, up_seconds, total_seconds, uptime_pct) VALUES (?,?,?,?,?)`,
nodeID, date, upSeconds, effectiveTotal, pct)
}
return pct
}
// --- Maintenance API ---
http.HandleFunc("/noc/api/maintenance", func(w http.ResponseWriter, r *http.Request) {
if !nocPin(r) { http.NotFound(w, r); return }
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case "POST":
var req struct {
Action string `json:"action"` // "start" or "stop"
Reason string `json:"reason"`
By string `json:"by"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"bad request"}`, 400)
return
}
if req.Action == "start" {
db.Exec(`INSERT INTO maintenance (reason, started_by) VALUES (?, ?)`, req.Reason, req.By)
w.Write([]byte(`{"ok":true,"status":"maintenance started"}`))
} else if req.Action == "stop" {
now := time.Now().Unix()
db.Exec(`UPDATE maintenance SET end_at = ?, ended_by = ? WHERE end_at IS NULL`, now, req.By)
w.Write([]byte(`{"ok":true,"status":"maintenance ended"}`))
} else {
http.Error(w, `{"error":"action must be start or stop"}`, 400)
}
case "GET":
rows, _ := db.Query(`SELECT id, start_at, end_at, reason, started_by, ended_by FROM maintenance ORDER BY id DESC LIMIT 20`)
type M struct {
ID int `json:"id"`
StartAt int64 `json:"start_at"`
EndAt *int64 `json:"end_at"`
Reason string `json:"reason"`
StartBy string `json:"started_by"`
EndBy string `json:"ended_by"`
}
var list []M
if rows != nil {
defer rows.Close()
for rows.Next() {
var m M
rows.Scan(&m.ID, &m.StartAt, &m.EndAt, &m.Reason, &m.StartBy, &m.EndBy)
list = append(list, m)
}
}
// Check if currently in maintenance
var active bool
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&active)
json.NewEncoder(w).Encode(map[string]any{"active": active, "windows": list})
}
})
// Public status page
http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "status", Title: "Status — clavitor", ActiveNav: "status"}
data.Pops = loadPops()
render(w, data)
})
// Status API — public, no PIN needed
http.HandleFunc("/status/api", func(w http.ResponseWriter, r *http.Request) {
pops := loadPops()
type DayUptime struct {
Date string `json:"date"`
Pct float64 `json:"pct"` // 0-100, -1 = no data
}
type NodeStatus struct {
ID string `json:"id"`
City string `json:"city"`
Country string `json:"country"`
Region string `json:"region"`
Status string `json:"status"`
Health string `json:"health"`
Uptime []DayUptime `json:"uptime_90"`
}
// Get latest telemetry per node
tRows, _ := db.Query(`SELECT t.node_id, t.received_at FROM telemetry t INNER JOIN (SELECT node_id, MAX(id) as max_id FROM telemetry GROUP BY node_id) latest ON t.id = latest.max_id`)
lastSeen := map[string]int64{}
if tRows != nil {
defer tRows.Close()
for tRows.Next() {
var nid string
var ts int64
tRows.Scan(&nid, &ts)
lastSeen[nid] = ts
}
}
// Build 90-day date list
now := time.Now()
var dates []string
for i := 89; i >= 0; i-- {
dates = append(dates, now.AddDate(0, 0, -i).Format("2006-01-02"))
}
// Check maintenance status
var inMaintenance bool
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaintenance)
var nodes []NodeStatus
allOperational := true
for _, p := range pops {
id := p.DNS
if idx := strings.Index(id, "."); idx > 0 {
id = id[:idx]
}
if id == "" { id = p.City }
health := "planned"
if p.Status == "live" {
health = "down"
if ts, ok := lastSeen[id]; ok {
age := now.Unix() - ts
if age < 150 {
health = "operational"
} else {
if inMaintenance { health = "maintenance" } else { health = "down" }
}
}
if health != "operational" && health != "maintenance" {
allOperational = false
}
}
// Build 90-day uptime from spans
uptime90 := make([]DayUptime, 90)
for i, d := range dates {
pct := rollupDay(id, d)
uptime90[i] = DayUptime{Date: d, Pct: pct}
}
nodes = append(nodes, NodeStatus{
ID: id, City: p.City, Country: countryName(p.Country),
Region: p.RegionName, Status: p.Status,
Health: health, Uptime: uptime90,
})
}
// Get recent incidents
type Incident struct {
ID int `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Date string `json:"date"`
}
var incidents []Incident
iRows, _ := db.Query(`SELECT id, title, status, date FROM incidents ORDER BY id DESC LIMIT 10`)
if iRows != nil {
defer iRows.Close()
for iRows.Next() {
var inc Incident
iRows.Scan(&inc.ID, &inc.Title, &inc.Status, &inc.Date)
incidents = append(incidents, inc)
}
}
// 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 {
overall = "Some Systems Degraded"
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
var lastBeat int64
db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat)
json.NewEncoder(w).Encode(map[string]any{
"overall": overall,
"nodes": nodes,
"incidents": incidents,
"outages": outages,
"dates": dates,
"last_heartbeat": lastBeat,
})
})
// Status API — day spans for tooltip
http.HandleFunc("/status/api/spans", func(w http.ResponseWriter, r *http.Request) {
node := r.URL.Query().Get("node")
date := r.URL.Query().Get("date")
if node == "" || date == "" {
http.Error(w, `{"error":"missing node or date"}`, 400)
return
}
dayStart, _ := time.Parse("2006-01-02", date)
dayEnd := dayStart.Add(24 * time.Hour)
dsUnix := dayStart.Unix()
deUnix := dayEnd.Unix()
if deUnix > time.Now().Unix() {
deUnix = time.Now().Unix()
}
// Don't count time before the node first came online
var firstEver int64
db.QueryRow(`SELECT MIN(start_at) FROM uptime_spans WHERE node_id = ?`, node).Scan(&firstEver)
if firstEver > 0 && firstEver > dsUnix {
dsUnix = firstEver
}
type Span struct {
Start int64 `json:"start"`
End int64 `json:"end"`
Type string `json:"type"` // "up" or "down"
}
var spans []Span
rows, err := db.Query(`SELECT start_at, end_at FROM uptime_spans WHERE node_id = ? AND end_at > ? AND start_at < ? ORDER BY start_at`, node, dsUnix, deUnix)
if err == nil {
prev := dsUnix
for rows.Next() {
var s, e int64
rows.Scan(&s, &e)
if s < dsUnix { s = dsUnix }
if e > deUnix { e = deUnix }
if s > prev {
spans = append(spans, Span{Start: prev, End: s, Type: "down"})
}
spans = append(spans, Span{Start: s, End: e, Type: "up"})
prev = e
}
rows.Close()
// Only mark trailing gap as "down" if it's significant (>60s)
// Gaps within heartbeat interval are just "not yet reported"
if prev < deUnix && (deUnix-prev) > 60 {
spans = append(spans, Span{Start: prev, End: deUnix, Type: "down"})
} else if prev < deUnix {
// Extend last up span to now (within heartbeat window)
if len(spans) > 0 && spans[len(spans)-1].Type == "up" {
spans[len(spans)-1].End = deUnix
}
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]any{"spans": spans, "day_start": dsUnix, "day_end": deUnix})
})
http.HandleFunc("/glass", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "glass", Title: "Looking Glass — clavitor", ActiveNav: "glass"}
data.Pops = loadPops()
render(w, data)
})
// Downloads API — list files with SHA-256 fingerprints
http.HandleFunc("/downloads/api", func(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir("downloads")
if err != nil {
http.Error(w, "[]", 500)
return
}
type dlInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
var files []dlInfo
for _, e := range entries {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue }
info, err := e.Info()
if err != nil { continue }
data, err := os.ReadFile(filepath.Join("downloads", e.Name()))
if err != nil { continue }
h := sha256.Sum256(data)
files = append(files, dlInfo{Name: e.Name(), Size: info.Size(), SHA256: hex.EncodeToString(h[:])})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
})
// 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")
w.Write([]byte("User-agent: *\nAllow: /\nDisallow: /noc\nDisallow: /telemetry\nDisallow: /glass\n\nSitemap: https://clavitor.ai/sitemap.xml\n"))
})
http.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) {
type PageInfo struct {
Path string
Priority string
Change string
}
pages := []PageInfo{
{"/", "1.0", "weekly"},
{"/hosted", "0.9", "weekly"},
{"/pricing", "0.9", "weekly"},
{"/install", "0.8", "monthly"},
{"/integrations/claude-code", "0.7", "monthly"},
{"/integrations/codex", "0.7", "monthly"},
{"/integrations/openclaw", "0.7", "monthly"},
{"/integrations/openclaw/cn", "0.6", "monthly"},
{"/privacy", "0.3", "yearly"},
{"/terms", "0.3", "yearly"},
{"/sources", "0.3", "yearly"},
}
lastmod := time.Now().Format("2006-01-02")
w.Header().Set("Content-Type", "application/xml")
xml := `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` + "\n"
for _, p := range pages {
xml += fmt.Sprintf("<url><loc>https://clavitor.ai%s</loc><lastmod>%s</lastmod><changefreq>%s</changefreq><priority>%s</priority></url>\n", p.Path, lastmod, p.Change, p.Priority)
}
xml += `</urlset>`
w.Write([]byte(xml))
})
http.HandleFunc("/security.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Contact: mailto:security@clavitor.ai\nPreferred-Languages: en\n"))
})
http.HandleFunc("/.well-known/security.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Contact: mailto:security@clavitor.ai\nPreferred-Languages: en\n"))
})
// Catch-all: index page at "/" or static files or .html redirects
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
data := PageData{Page: "index", Title: "clavitor — AI-native password manager", Desc: "Field-level encryption for password managers that live alongside AI assistants. Your AI gets what it needs. Your secrets stay yours."}
data.Pops = loadPops()
render(w, data)
return
}
// Redirect old .html URLs to clean paths
if strings.HasSuffix(r.URL.Path, ".html") {
clean := strings.TrimSuffix(r.URL.Path, ".html")
if clean == "/index" {
clean = "/"
}
http.Redirect(w, r, clean, http.StatusMovedPermanently)
return
}
http.FileServer(http.FS(static)).ServeHTTP(w, r)
})
log.Printf("clavitor-web starting on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}