diff --git a/clavis/clavis-vault/Makefile b/clavis/clavis-vault/Makefile index 50554c2..520a5cf 100644 --- a/clavis/clavis-vault/Makefile +++ b/clavis/clavis-vault/Makefile @@ -6,15 +6,15 @@ DATE := $(shell date -u +%Y%m%d-%H%M) LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE) -# Deploy targets -REMOTE := clavitor-hq -REMOTE_PATH := /opt/clavitor/bin +# Download hosting +SITE_HOST := root@clavitor.ai +SITE_DIR := /opt/clavitor-web/downloads export GOFIPS140 := latest # --- Build targets --- -.PHONY: build build-all linux-amd64 linux-arm64 release test clean deploy +.PHONY: build build-all linux-amd64 linux-arm64 test clean publish build: linux-amd64 @@ -28,20 +28,16 @@ linux-arm64: CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \ $(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/clavitor -release: - ./scripts/release.sh - test: $(GO) test ./... clean: rm -f $(BINARY)-linux-amd64 $(BINARY)-linux-arm64 -# --- Deploy --- +# --- Publish binaries to clavitor.ai/download --- -deploy: linux-amd64 - scp $(BINARY)-linux-amd64 $(REMOTE):/tmp/$(BINARY)-new - ssh $(REMOTE) 'sudo systemctl stop clavitor && mv /tmp/$(BINARY)-new $(REMOTE_PATH)/$(BINARY) && chmod +x $(REMOTE_PATH)/$(BINARY) && sudo systemctl start clavitor' - @echo "Deployed. Verifying..." - @sleep 2 - @ssh $(REMOTE) 'sudo systemctl is-active clavitor' +publish: build-all + ssh $(SITE_HOST) 'mkdir -p $(SITE_DIR)' + scp $(BINARY)-linux-amd64 $(SITE_HOST):$(SITE_DIR)/ + scp $(BINARY)-linux-arm64 $(SITE_HOST):$(SITE_DIR)/ + @echo "Published $(VERSION) to clavitor.ai/download" diff --git a/clavis/clavis-vault/clavitor-linux-amd64 b/clavis/clavis-vault/clavitor-linux-amd64 index 59d373b..d327cbb 100755 Binary files a/clavis/clavis-vault/clavitor-linux-amd64 and b/clavis/clavis-vault/clavitor-linux-amd64 differ diff --git a/clavis/clavis-vault/clavitor-linux-arm64 b/clavis/clavis-vault/clavitor-linux-arm64 index c8f112c..ee4aa2e 100755 Binary files a/clavis/clavis-vault/clavitor-linux-arm64 and b/clavis/clavis-vault/clavitor-linux-arm64 differ diff --git a/clavis/clavis-vault/lib/tls.go b/clavis/clavis-vault/lib/tls.go index d23f160..eda9b7d 100644 --- a/clavis/clavis-vault/lib/tls.go +++ b/clavis/clavis-vault/lib/tls.go @@ -2,19 +2,30 @@ package lib import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" "log" + "math/big" + "net" "net/http" "os" + "path/filepath" + "time" "github.com/caddyserver/certmagic" "github.com/libdns/cloudflare" "github.com/mholt/acmez/v3/acme" ) -// TLSConfig holds configuration for automatic TLS via Let's Encrypt DNS-01. +// TLSConfig holds configuration for TLS. type TLSConfig struct { - Domain string // e.g. "use1.clavitor.ai" + Domain string // e.g. "use1.clavitor.ai" CFToken string // Cloudflare API token for DNS-01 challenge DataDir string // directory to store certs Email string // ACME account email @@ -30,15 +41,21 @@ func LoadTLSConfig() TLSConfig { } } -// ListenAndServeTLS starts an HTTPS server on the given address using -// Let's Encrypt certificates obtained via Cloudflare DNS-01 challenge. -// Falls back to plain HTTP if TLS is not configured. +// ListenAndServeTLS starts an HTTPS server. +// +// Three modes: +// - TLS_DOMAIN + CF_API_TOKEN set → Let's Encrypt via Cloudflare DNS-01 +// - Neither set → self-signed certificate (generated on first run) +// - Plain HTTP is never used func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error { - if cfg.Domain == "" || cfg.CFToken == "" { - log.Printf("TLS not configured (missing TLS_DOMAIN or CF_API_TOKEN), serving plain HTTP") - return http.ListenAndServe(addr, handler) + if cfg.Domain != "" && cfg.CFToken != "" { + return listenLetsEncrypt(addr, handler, cfg) } + return listenSelfSigned(addr, handler, cfg) +} +// listenLetsEncrypt uses certmagic for automatic Let's Encrypt certificates. +func listenLetsEncrypt(addr string, handler http.Handler, cfg TLSConfig) error { if cfg.DataDir == "" { cfg.DataDir = "/opt/clavitor/certs" } @@ -46,7 +63,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error { cfg.Email = "ops@clavitor.ai" } - // Configure certmagic certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Email = cfg.Email certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{ @@ -60,7 +76,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error { magic := certmagic.NewDefault() magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir} - // Obtain/renew cert ctx := context.Background() if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil { return fmt.Errorf("certmagic manage %s: %w", cfg.Domain, err) @@ -76,10 +91,93 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error { } log.Printf("Clavitor listening on https://0.0.0.0%s (%s)", addr, cfg.Domain) - // TLS certs are managed by certmagic, pass empty cert/key paths return server.ListenAndServeTLS("", "") } +// listenSelfSigned generates a self-signed certificate and serves HTTPS. +// Cert is stored in DataDir (or working directory) so it persists across restarts. +// The browser will show a certificate warning — the connection is still encrypted. +func listenSelfSigned(addr string, handler http.Handler, cfg TLSConfig) error { + certDir := cfg.DataDir + if certDir == "" { + certDir = "." + } + certPath := filepath.Join(certDir, "clavitor.crt") + keyPath := filepath.Join(certDir, "clavitor.key") + + if err := ensureSelfSignedCert(certPath, keyPath); err != nil { + return fmt.Errorf("self-signed cert: %w", err) + } + + server := &http.Server{ + Addr: addr, + Handler: handler, + } + + log.Printf("Clavitor listening on https://0.0.0.0%s (self-signed)", addr) + return server.ListenAndServeTLS(certPath, keyPath) +} + +// ensureSelfSignedCert creates a self-signed TLS certificate if one doesn't exist. +// Valid for 10 years, covers localhost, 127.0.0.1, and all local network IPs. +func ensureSelfSignedCert(certPath, keyPath string) error { + if _, err := os.Stat(certPath); err == nil { + if _, err := os.Stat(keyPath); err == nil { + return nil // both exist + } + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{Organization: []string{"Clavitor"}, CommonName: "clavitor"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + } + + // Add all local interface IPs so LAN access works + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP) + } + } + } + + certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + return err + } + + // Write cert and key as PEM + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + return err + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + return err + } + + log.Printf("Generated self-signed TLS certificate: %s", certPath) + return nil +} + // ChallengeError wraps ACME errors with actionable context. func ChallengeError(err error) string { if acmeErr, ok := err.(acme.Problem); ok { @@ -87,3 +185,24 @@ func ChallengeError(err error) string { } return err.Error() } + +// SelfSignedTLSConfig returns a tls.Config with a fresh self-signed certificate. +// Used by tests that need HTTPS without filesystem cert files. +func SelfSignedTLSConfig() *tls.Config { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + certDER, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + cert := tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + } + return &tls.Config{Certificates: []tls.Certificate{cert}} +} diff --git a/clavitor.com/clavitor-web-linux-amd64 b/clavitor.com/clavitor-web-linux-amd64 index 9ff34d2..0ff7c29 100755 Binary files a/clavitor.com/clavitor-web-linux-amd64 and b/clavitor.com/clavitor-web-linux-amd64 differ diff --git a/clavitor.com/clavitor.css b/clavitor.com/clavitor.css index 217f8c5..b5ba93c 100644 --- a/clavitor.com/clavitor.css +++ b/clavitor.com/clavitor.css @@ -147,6 +147,7 @@ code { font-size: 0.875em; } /* === TABLES === */ .data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } .data-table th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); padding: 8px 12px; border-bottom: 2px solid var(--border); } +.data-table th:first-child, .data-table td:first-child { padding-left: 0; } .data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); } .data-table tr:last-child td { border-bottom: none; } @@ -328,6 +329,7 @@ code { font-size: 0.875em; } } .glass-status-live { background: var(--brand-black); color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } .glass-status-planned { background: var(--surface); color: var(--muted); } +.glass-status-outage { background: #dc2626; color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } .glass-country { font-size: 0.72rem; color: var(--muted); margin-left: 6px; text-transform: uppercase; letter-spacing: 0.05em; } .glass-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; } .glass-latency-left { display: flex; flex-direction: column; } diff --git a/clavitor.com/clavitor.db b/clavitor.com/clavitor.db index 0b4d3e5..d9d6f82 100644 Binary files a/clavitor.com/clavitor.db and b/clavitor.com/clavitor.db differ diff --git a/clavitor.com/clavitor.db-shm b/clavitor.com/clavitor.db-shm deleted file mode 100644 index 4b5aaf8..0000000 Binary files a/clavitor.com/clavitor.db-shm and /dev/null differ diff --git a/clavitor.com/clavitor.db-wal b/clavitor.com/clavitor.db-wal deleted file mode 100644 index 61cfe6f..0000000 Binary files a/clavitor.com/clavitor.db-wal and /dev/null differ diff --git a/clavitor.com/main.go b/clavitor.com/main.go index 4588c31..90aee1c 100644 --- a/clavitor.com/main.go +++ b/clavitor.com/main.go @@ -13,6 +13,7 @@ import ( "net" "net/http" "os" + "path/filepath" "sort" "strings" "time" @@ -29,6 +30,7 @@ var static embed.FS var templates *template.Template var devMode bool var db *sql.DB +var processStartTime = time.Now().Unix() var countryNames = map[string]string{ "US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia", @@ -345,18 +347,19 @@ func main() { t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode) // Uptime span tracking: extend existing span or create new one - // During maintenance: always extend, never create new (no false gaps) - // Normal: gap threshold 60s (2x the 30s interval, allows one missed beat) + // Skip span updates if we just restarted — we can't judge gaps yet now := time.Now().Unix() - var inMaint bool - db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint) - var spanID int64 - var spanEnd int64 - err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd) - if err == nil && (inMaint || (now-spanEnd) <= 60) { - db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID) - } else if !inMaint { - db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now) + if (now - processStartTime) >= 60 { + var inMaint bool + db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint) + var spanID int64 + var spanEnd int64 + err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd) + if err == nil && (inMaint || (now-spanEnd) <= 60) { + db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID) + } else if !inMaint { + db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now) + } } // Legacy daily uptime (kept for backwards compat) @@ -737,10 +740,30 @@ func main() { } } + // Outages + type Outage struct { + ID int `json:"id"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` + NodeID string `json:"node_id"` + Status string `json:"status"` + Description string `json:"description"` + } + var outages []Outage + oRows, _ := db.Query(`SELECT id, start_at, COALESCE(end_at,''), node_id, status, description FROM outages ORDER BY id DESC`) + if oRows != nil { + defer oRows.Close() + for oRows.Next() { + var o Outage + oRows.Scan(&o.ID, &o.StartAt, &o.EndAt, &o.NodeID, &o.Status, &o.Description) + outages = append(outages, o) + } + } + overall := "All Systems Operational" if inMaintenance { overall = "Scheduled Maintenance" - } else if !allOperational { + } else if !allOperational || len(outages) > 0 { overall = "Some Systems Degraded" } @@ -753,6 +776,7 @@ func main() { "overall": overall, "nodes": nodes, "incidents": incidents, + "outages": outages, "dates": dates, "last_heartbeat": lastBeat, }) @@ -825,6 +849,23 @@ func main() { render(w, data) }) + // Downloads — direct file serving, no traversal + http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/download/") + if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") || strings.HasPrefix(name, ".") { + http.NotFound(w, r) + return + } + path := filepath.Join("downloads", name) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"") + http.ServeFile(w, r, path) + }) + // SEO http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") diff --git a/clavitor.com/templates/footer.tmpl b/clavitor.com/templates/footer.tmpl index 9342cc7..1bfaf92 100644 --- a/clavitor.com/templates/footer.tmpl +++ b/clavitor.com/templates/footer.tmpl @@ -6,7 +6,7 @@ clavitor GitHub Discord - X + X