1350 lines
46 KiB
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)
|
|
}
|
|
}
|