diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1019db9 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# arcline-portal environment variables +# Copy to .env and fill in real values before running. + +# HTTP listen address +PORT=8082 + +# Path to the portal's own SQLite database +DB_PATH=./portal.db + +# Path to arcline-uptime's SQLite database (read-only) +UPTIME_DB_PATH=../arcline-uptime/uptime.db + +# Secret key for session token HMAC (generate with: openssl rand -hex 32) +SESSION_SECRET=changeme + +# SMTP — used for password reset and ticket notification emails +SMTP_HOST=mail.arclineit.com +SMTP_PORT=587 +SMTP_USER=portal@arclineit.com +SMTP_PASS=changeme +SMTP_FROM=portal@arclineit.com + +# Admin notification email (new tickets, alerts) +ADMIN_EMAIL=blake@arclineit.com + +# Base URL (used in email links) +BASE_URL=https://portal.arclineit.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b90572 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +arcline-portal +arcline-portal-linux-amd64 +arcline-portal-linux-arm64 +*.db +.env +*.test +*.out diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d993a43 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +BINARY := arcline-portal +MODULE := arclineit/arcline-portal +GO := go +GOFLAGS := -trimpath -ldflags="-s -w" + +.PHONY: build run linux-amd64 linux-arm64 all test clean + +build: + $(GO) build $(GOFLAGS) -o $(BINARY) . + +run: + $(GO) run . + +linux-amd64: + GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -o $(BINARY)-linux-amd64 . + +linux-arm64: + GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -o $(BINARY)-linux-arm64 . + +all: linux-amd64 linux-arm64 + +test: + $(GO) test ./... + +clean: + rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64 diff --git a/README.md b/README.md index 6fa483b..ef59fc0 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,110 @@ # arcline-portal -Customer dashboard for arclineit.com. Provides SSL expiry monitoring, one-click static site deployment, a log viewer, and a basic support ticket system — without requiring customers to SSH into anything. +Customer dashboard for arclineit.com. Provides SSL expiry monitoring and a support ticket system — without requiring customers to SSH into anything. Sits alongside WHMCS for billing; handles everything WHMCS doesn't. -## Status - -Planned. Not yet started. - ## Stack - Go backend, vanilla HTML/CSS/JS (Arcline design system) -- PostgreSQL or SQLite -- Session-based auth with optional TOTP 2FA -- Ships as a single binary with embedded static assets +- SQLite (single file, no server required) +- Session-based auth (bcrypt + secure cookies) +- Ships as a single binary with embedded static assets and templates ## Modules ### SSL Expiry Dashboard -Customers add domains; the system checks cert expiry daily and sends alerts at 30/14/7 days. Color-coded: green > 30d, amber 14–30d, red < 14d. - -### Static Deployment -Connect a GitLab repo or upload a zip. On push to main, Arcline pulls, builds, and deploys via rsync. Supports static HTML, Hugo, Jekyll, plain PHP. Last 3 deployments kept for rollback. - -### Log Viewer -Browse access/error logs in the browser. Filter by date, status code, IP, path. Live tail via SSE. +Customers add domains; the system checks cert expiry daily via TLS dial and displays status color-coded: green > 30d, amber 14–30d, red < 14d. ### Support Tickets -Customer opens a ticket; Blake gets an email. Replies go back into the thread. No third-party helpdesk. - -## Environment variables - -To be documented once scaffold is started. +Customer opens a ticket; Blake gets an email. Replies go back into the thread from the portal UI. No third-party helpdesk. ## Deployment -Single binary + systemd unit behind nginx. See [todo.md](todo.md) for the full task list. +### Prerequisites + +- Linux server (amd64 or arm64) +- nginx +- An `arcline` system user + +### Build + +```sh +# Local binary +make build + +# Cross-compile for Linux +make linux-amd64 +make linux-arm64 +``` + +### Install + +```sh +# Create directories and user +sudo useradd -r -s /sbin/nologin -d /opt/arcline-portal arcline +sudo mkdir -p /opt/arcline-portal +sudo chown arcline:arcline /opt/arcline-portal + +# Copy binary +sudo cp arcline-portal-linux-amd64 /opt/arcline-portal/arcline-portal +sudo chmod +x /opt/arcline-portal/arcline-portal + +# Copy and populate env file +sudo cp .env.example /opt/arcline-portal/.env +sudo chown arcline:arcline /opt/arcline-portal/.env +sudo chmod 600 /opt/arcline-portal/.env +# Edit /opt/arcline-portal/.env and fill in real values +``` + +### systemd + +```sh +sudo cp deploy/arcline-portal.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now arcline-portal +sudo systemctl status arcline-portal +``` + +### nginx + +```sh +sudo cp deploy/nginx-portal.conf /etc/nginx/sites-available/arcline-portal +sudo ln -s /etc/nginx/sites-available/arcline-portal /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +Expects TLS certificates at: +- `/etc/ssl/arclineit.com/fullchain.pem` +- `/etc/ssl/arclineit.com/privkey.pem` + +### Seed first admin account + +```sh +sudo -u arcline /opt/arcline-portal/arcline-portal \ + -seed \ + -username blake \ + -name "Blake" \ + -password "changeme" +``` + +## Environment variables + +Copy `.env.example` to `.env` and set the following: + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8082` | HTTP listen port (nginx proxies to this) | +| `DB_PATH` | `./portal.db` | Path to the portal SQLite database | +| `UPTIME_DB_PATH` | `../arcline-uptime/uptime.db` | Path to arcline-uptime's database (read-only); omit if not using uptime integration | +| `SESSION_SECRET` | — | 32-byte hex secret for session tokens. Generate with: `openssl rand -hex 32` | +| `SMTP_HOST` | `mail.arclineit.com` | SMTP server hostname | +| `SMTP_PORT` | `587` | SMTP port (STARTTLS) | +| `SMTP_USER` | — | SMTP username | +| `SMTP_PASS` | — | SMTP password | +| `SMTP_FROM` | `portal@arclineit.com` | From address for outbound email | +| `ADMIN_EMAIL` | `blake@arclineit.com` | Receives new ticket notifications | +| `BASE_URL` | `https://portal.arclineit.com` | Base URL used in email links (no trailing slash) | ## License diff --git a/deploy/arcline-portal.service b/deploy/arcline-portal.service new file mode 100644 index 0000000..56d4b25 --- /dev/null +++ b/deploy/arcline-portal.service @@ -0,0 +1,23 @@ +[Unit] +Description=Arcline Portal — customer dashboard +After=network.target + +[Service] +Type=simple +User=arcline +Group=arcline +WorkingDirectory=/opt/arcline-portal +EnvironmentFile=/opt/arcline-portal/.env +ExecStart=/opt/arcline-portal/arcline-portal +Restart=on-failure +RestartSec=5s + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/arcline-portal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-portal.conf b/deploy/nginx-portal.conf new file mode 100644 index 0000000..87786e9 --- /dev/null +++ b/deploy/nginx-portal.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name portal.arclineit.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name portal.arclineit.com; + + ssl_certificate /etc/ssl/arclineit.com/fullchain.pem; + ssl_certificate_key /etc/ssl/arclineit.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + + location / { + proxy_pass http://127.0.0.1:8082; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8b97288 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module arclineit/arcline-portal + +go 1.25.7 + +require ( + golang.org/x/crypto v0.37.0 + modernc.org/sqlite v1.47.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..172603d --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b7c163c --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" + "time" + + "arclineit/arcline-portal/internal/db" + "golang.org/x/crypto/bcrypt" +) + +type contextKey string + +const clientKey contextKey = "client" + +const SessionCookie = "arc_session" +const SessionTTL = 30 * 24 * time.Hour // 30 days + +// HashPassword returns a bcrypt hash of the password. +func HashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +// CheckPassword reports whether password matches the stored hash. +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// GenerateToken returns a 32-byte hex-encoded random token. +func GenerateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// SetSessionCookie writes a secure session cookie to the response. +func SetSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookie, + Value: token, + Path: "/", + Expires: time.Now().Add(SessionTTL), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSessionCookie removes the session cookie. +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// Middleware validates the session cookie and injects the client into context. +// Redirects to /login on missing or invalid session. +func Middleware(database *db.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookie) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + client, err := database.GetClientBySession(cookie.Value) + if err != nil || client == nil { + ClearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), clientKey, client) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// AdminMiddleware enforces that the authenticated client is an admin. +// Must be used after Middleware. +func AdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client := ClientFromContext(r.Context()) + if client == nil || !client.IsAdmin { + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// ClientFromContext retrieves the authenticated client from context. +// Returns nil if not present. +func ClientFromContext(ctx context.Context) *db.Client { + c, _ := ctx.Value(clientKey).(*db.Client) + return c +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..845a5fc --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,605 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// DB wraps the portal SQLite database. +type DB struct { + db *sql.DB +} + +// --- Model types --- + +type Client struct { + ID int64 + Username string + DisplayName string + Email string + PasswordHash string + IsAdmin bool + CreatedAt time.Time +} + +type Session struct { + Token string + ClientID int64 + ExpiresAt time.Time +} + +// Domain is a customer-owned domain tracked for SSL expiry. +type Domain struct { + ID int64 + ClientID int64 + Domain string + AddedAt time.Time + LastCheckedAt time.Time + ExpiresAt time.Time + DaysRemaining int + IsValid bool + CheckError string +} + +// Monitor links a client to a monitor name in arcline-uptime. +type Monitor struct { + ID int64 + ClientID int64 + MonitorName string + Label string // human-friendly display name +} + +type TicketStatus string + +const ( + TicketOpen TicketStatus = "open" + TicketInProgress TicketStatus = "in_progress" + TicketClosed TicketStatus = "closed" +) + +type Ticket struct { + ID int64 + ClientID int64 + ClientName string // populated by ListAllTickets (admin view) + Subject string + Status TicketStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +type TicketMessage struct { + ID int64 + TicketID int64 + Body string + FromAdmin bool + CreatedAt time.Time +} + +type PasswordReset struct { + Token string + ClientID int64 + ExpiresAt time.Time + Used bool +} + +// --- Open / migrate --- + +func Open(path string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", path+"?_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + sqlDB.SetMaxOpenConns(1) + + d := &DB{db: sqlDB} + if err := d.migrate(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + return d, nil +} + +func (d *DB) Close() error { return d.db.Close() } + +func (d *DB) migrate() error { + _, err := d.db.Exec(` + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id); + + CREATE TABLE IF NOT EXISTS password_resets ( + token TEXT PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS client_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + monitor_name TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + UNIQUE(client_id, monitor_name) + ); + + CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + added_at INTEGER NOT NULL DEFAULT (unixepoch()), + last_checked_at INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL DEFAULT 0, + days_remaining INTEGER NOT NULL DEFAULT 0, + is_valid INTEGER NOT NULL DEFAULT 0, + check_error TEXT NOT NULL DEFAULT '', + UNIQUE(client_id, domain) + ); + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets(client_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS ticket_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + body TEXT NOT NULL, + from_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_messages_ticket ON ticket_messages(ticket_id, created_at ASC); + `) + if err != nil { + return err + } + // Add email column to existing databases — SQLite has no IF NOT EXISTS for columns. + _, _ = d.db.Exec(`ALTER TABLE clients ADD COLUMN email TEXT NOT NULL DEFAULT ''`) + return nil +} + +// --- Client queries --- + +func (d *DB) CreateClient(username, displayName, email, passwordHash string, isAdmin bool) (*Client, error) { + res, err := d.db.Exec( + `INSERT INTO clients (username, display_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)`, + username, displayName, email, passwordHash, boolToInt(isAdmin), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return d.GetClientByID(id) +} + +func (d *DB) GetClientByID(id int64) (*Client, error) { + row := d.db.QueryRow( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE id = ?`, id, + ) + return scanClient(row) +} + +func (d *DB) GetClientByUsername(username string) (*Client, error) { + row := d.db.QueryRow( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE username = ?`, username, + ) + return scanClient(row) +} + +func (d *DB) GetClientByEmail(email string) (*Client, error) { + row := d.db.QueryRow( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE email = ? AND email != ''`, email, + ) + return scanClient(row) +} + +func (d *DB) UpdateClientEmail(clientID int64, email string) error { + _, err := d.db.Exec(`UPDATE clients SET email = ? WHERE id = ?`, email, clientID) + return err +} + +func (d *DB) ListClients() ([]Client, error) { + rows, err := d.db.Query( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients ORDER BY display_name`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Client + for rows.Next() { + c, err := scanClientFromRows(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func (d *DB) UpdateClientPassword(clientID int64, hash string) error { + _, err := d.db.Exec(`UPDATE clients SET password_hash = ? WHERE id = ?`, hash, clientID) + return err +} + +func (d *DB) DeleteClient(id int64) error { + _, err := d.db.Exec(`DELETE FROM clients WHERE id = ?`, id) + return err +} + +// --- Session queries --- + +func (d *DB) CreateSession(token string, clientID int64, expiresAt time.Time) error { + _, err := d.db.Exec( + `INSERT INTO sessions (token, client_id, expires_at) VALUES (?, ?, ?)`, + token, clientID, expiresAt.Unix(), + ) + return err +} + +func (d *DB) GetClientBySession(token string) (*Client, error) { + var clientID int64 + var exp int64 + err := d.db.QueryRow( + `SELECT client_id, expires_at FROM sessions WHERE token = ?`, token, + ).Scan(&clientID, &exp) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("session not found") + } + if err != nil { + return nil, err + } + if time.Now().After(time.Unix(exp, 0)) { + _ = d.DeleteSession(token) + return nil, fmt.Errorf("session expired") + } + return d.GetClientByID(clientID) +} + +func (d *DB) DeleteSession(token string) error { + _, err := d.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return err +} + +func (d *DB) PruneSessions() error { + _, err := d.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix()) + return err +} + +// --- Password reset queries --- + +func (d *DB) CreatePasswordReset(token string, clientID int64) error { + exp := time.Now().Add(time.Hour).Unix() + _, err := d.db.Exec( + `INSERT INTO password_resets (token, client_id, expires_at) VALUES (?, ?, ?)`, + token, clientID, exp, + ) + return err +} + +func (d *DB) UsePasswordReset(token string) (int64, error) { + var clientID int64 + var exp int64 + var used int + err := d.db.QueryRow( + `SELECT client_id, expires_at, used FROM password_resets WHERE token = ?`, token, + ).Scan(&clientID, &exp, &used) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("reset token not found") + } + if err != nil { + return 0, err + } + if used != 0 { + return 0, fmt.Errorf("reset token already used") + } + if time.Now().After(time.Unix(exp, 0)) { + return 0, fmt.Errorf("reset token expired") + } + _, err = d.db.Exec(`UPDATE password_resets SET used = 1 WHERE token = ?`, token) + return clientID, err +} + +// --- Monitor queries --- + +func (d *DB) AddMonitor(clientID int64, monitorName, label string) error { + _, err := d.db.Exec( + `INSERT OR IGNORE INTO client_monitors (client_id, monitor_name, label) VALUES (?, ?, ?)`, + clientID, monitorName, label, + ) + return err +} + +func (d *DB) RemoveMonitor(id int64) error { + _, err := d.db.Exec(`DELETE FROM client_monitors WHERE id = ?`, id) + return err +} + +func (d *DB) ListMonitors(clientID int64) ([]Monitor, error) { + rows, err := d.db.Query( + `SELECT id, client_id, monitor_name, label FROM client_monitors WHERE client_id = ? ORDER BY label, monitor_name`, + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Monitor + for rows.Next() { + var m Monitor + if err := rows.Scan(&m.ID, &m.ClientID, &m.MonitorName, &m.Label); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// --- Domain queries --- + +func (d *DB) AddDomain(clientID int64, domain string) error { + _, err := d.db.Exec( + `INSERT OR IGNORE INTO domains (client_id, domain) VALUES (?, ?)`, + clientID, domain, + ) + return err +} + +func (d *DB) RemoveDomain(id int64) error { + _, err := d.db.Exec(`DELETE FROM domains WHERE id = ?`, id) + return err +} + +func (d *DB) UpdateDomainStatus(id int64, expiresAt time.Time, daysRemaining int, isValid bool, checkErr string) error { + _, err := d.db.Exec( + `UPDATE domains SET last_checked_at = ?, expires_at = ?, days_remaining = ?, is_valid = ?, check_error = ? WHERE id = ?`, + time.Now().Unix(), expiresAt.Unix(), daysRemaining, boolToInt(isValid), checkErr, id, + ) + return err +} + +func (d *DB) ListDomains(clientID int64) ([]Domain, error) { + rows, err := d.db.Query( + `SELECT id, client_id, domain, added_at, last_checked_at, expires_at, days_remaining, is_valid, check_error + FROM domains WHERE client_id = ? ORDER BY domain`, + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDomains(rows) +} + +// AllDomainsForCheck returns all domains across all clients (for the background checker). +func (d *DB) AllDomainsForCheck() ([]Domain, error) { + rows, err := d.db.Query( + `SELECT id, client_id, domain, added_at, last_checked_at, expires_at, days_remaining, is_valid, check_error FROM domains`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDomains(rows) +} + +// --- Ticket queries --- + +func (d *DB) CreateTicket(clientID int64, subject, body string) (*Ticket, error) { + res, err := d.db.Exec( + `INSERT INTO tickets (client_id, subject) VALUES (?, ?)`, clientID, subject, + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + _, err = d.db.Exec( + `INSERT INTO ticket_messages (ticket_id, body, from_admin) VALUES (?, ?, 0)`, id, body, + ) + if err != nil { + return nil, err + } + return d.GetTicket(id) +} + +func (d *DB) GetTicket(id int64) (*Ticket, error) { + row := d.db.QueryRow( + `SELECT id, client_id, subject, status, created_at, updated_at FROM tickets WHERE id = ?`, id, + ) + return scanTicket(row) +} + +func (d *DB) ListTickets(clientID int64) ([]Ticket, error) { + rows, err := d.db.Query( + `SELECT id, client_id, subject, status, created_at, updated_at + FROM tickets WHERE client_id = ? ORDER BY updated_at DESC`, + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ticket + for rows.Next() { + t, err := scanTicketFromRows(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (d *DB) ListAllTickets() ([]Ticket, error) { + rows, err := d.db.Query( + `SELECT t.id, t.client_id, c.display_name, t.subject, t.status, t.created_at, t.updated_at + FROM tickets t + JOIN clients c ON c.id = t.client_id + ORDER BY t.updated_at DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ticket + for rows.Next() { + var t Ticket + var createdTS, updatedTS int64 + if err := rows.Scan(&t.ID, &t.ClientID, &t.ClientName, &t.Subject, &t.Status, &createdTS, &updatedTS); err != nil { + return nil, err + } + t.CreatedAt = time.Unix(createdTS, 0) + t.UpdatedAt = time.Unix(updatedTS, 0) + out = append(out, t) + } + return out, rows.Err() +} + +func (d *DB) AddTicketMessage(ticketID int64, body string, fromAdmin bool) error { + _, err := d.db.Exec( + `INSERT INTO ticket_messages (ticket_id, body, from_admin) VALUES (?, ?, ?)`, + ticketID, body, boolToInt(fromAdmin), + ) + if err != nil { + return err + } + _, err = d.db.Exec( + `UPDATE tickets SET updated_at = unixepoch() WHERE id = ?`, ticketID, + ) + return err +} + +func (d *DB) SetTicketStatus(id int64, status TicketStatus) error { + _, err := d.db.Exec(`UPDATE tickets SET status = ?, updated_at = unixepoch() WHERE id = ?`, string(status), id) + return err +} + +func (d *DB) GetTicketMessages(ticketID int64) ([]TicketMessage, error) { + rows, err := d.db.Query( + `SELECT id, ticket_id, body, from_admin, created_at FROM ticket_messages WHERE ticket_id = ? ORDER BY created_at ASC`, + ticketID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TicketMessage + for rows.Next() { + var m TicketMessage + var ts int64 + var fa int + if err := rows.Scan(&m.ID, &m.TicketID, &m.Body, &fa, &ts); err != nil { + return nil, err + } + m.FromAdmin = fa != 0 + m.CreatedAt = time.Unix(ts, 0) + out = append(out, m) + } + return out, rows.Err() +} + +// --- Helpers --- + +func scanClient(row *sql.Row) (*Client, error) { + var c Client + var ts int64 + var admin int + err := row.Scan(&c.ID, &c.Username, &c.DisplayName, &c.Email, &c.PasswordHash, &admin, &ts) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + c.IsAdmin = admin != 0 + c.CreatedAt = time.Unix(ts, 0) + return &c, nil +} + +func scanClientFromRows(rows *sql.Rows) (*Client, error) { + var c Client + var ts int64 + var admin int + if err := rows.Scan(&c.ID, &c.Username, &c.DisplayName, &c.Email, &c.PasswordHash, &admin, &ts); err != nil { + return nil, err + } + c.IsAdmin = admin != 0 + c.CreatedAt = time.Unix(ts, 0) + return &c, nil +} + +func scanDomains(rows *sql.Rows) ([]Domain, error) { + var out []Domain + for rows.Next() { + var dom Domain + var addedTS, checkedTS, expiresTS int64 + var valid int + if err := rows.Scan( + &dom.ID, &dom.ClientID, &dom.Domain, + &addedTS, &checkedTS, &expiresTS, + &dom.DaysRemaining, &valid, &dom.CheckError, + ); err != nil { + return nil, err + } + dom.AddedAt = time.Unix(addedTS, 0) + dom.LastCheckedAt = time.Unix(checkedTS, 0) + dom.ExpiresAt = time.Unix(expiresTS, 0) + dom.IsValid = valid != 0 + out = append(out, dom) + } + return out, rows.Err() +} + +func scanTicket(row *sql.Row) (*Ticket, error) { + var t Ticket + var createdTS, updatedTS int64 + err := row.Scan(&t.ID, &t.ClientID, &t.Subject, &t.Status, &createdTS, &updatedTS) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + t.CreatedAt = time.Unix(createdTS, 0) + t.UpdatedAt = time.Unix(updatedTS, 0) + return &t, nil +} + +func scanTicketFromRows(rows *sql.Rows) (*Ticket, error) { + var t Ticket + var createdTS, updatedTS int64 + if err := rows.Scan(&t.ID, &t.ClientID, &t.Subject, &t.Status, &createdTS, &updatedTS); err != nil { + return nil, err + } + t.CreatedAt = time.Unix(createdTS, 0) + t.UpdatedAt = time.Unix(updatedTS, 0) + return &t, nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..42f2d8a --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,124 @@ +// Package mail sends transactional emails via SMTP with STARTTLS. +package mail + +import ( + "bytes" + "fmt" + "net" + "net/smtp" + "strings" + "time" +) + +// Config holds SMTP connection settings sourced from environment variables. +type Config struct { + Host string // SMTP_HOST + Port string // SMTP_PORT (default "587") + Username string // SMTP_USER + Password string // SMTP_PASS + From string // SMTP_FROM + AdminEmail string // ADMIN_EMAIL + BaseURL string // BASE_URL (used in link generation) +} + +// Mailer sends email using the provided Config. +type Mailer struct { + cfg Config +} + +// New returns a Mailer. Returns an error if required fields are missing. +func New(cfg Config) (*Mailer, error) { + if cfg.Host == "" || cfg.From == "" { + return nil, fmt.Errorf("mail: SMTP_HOST and SMTP_FROM are required") + } + if cfg.Port == "" { + cfg.Port = "587" + } + return &Mailer{cfg: cfg}, nil +} + +// Configured reports whether the mailer has enough config to send. +func (m *Mailer) Configured() bool { + return m.cfg.Host != "" && m.cfg.From != "" +} + +// SendPasswordReset sends a password-reset link to the given address. +func (m *Mailer) SendPasswordReset(toAddr, toName, token string) error { + link := strings.TrimRight(m.cfg.BaseURL, "/") + "/reset?token=" + token + subject := "Reset your Arcline Portal password" + body := fmt.Sprintf(`Hi %s, + +Someone requested a password reset for your Arcline Portal account. +If that was you, click the link below to set a new password. +The link expires in 1 hour. + + %s + +If you did not request this, you can safely ignore this email. + +— Arcline IT +`, toName, link) + return m.send(toAddr, subject, body) +} + +// SendTicketCreated notifies the admin that a new ticket was opened. +func (m *Mailer) SendTicketCreated(clientName, subject, body string, ticketID int64) error { + if m.cfg.AdminEmail == "" { + return nil + } + link := strings.TrimRight(m.cfg.BaseURL, "/") + fmt.Sprintf("/tickets/%d", ticketID) + msg := fmt.Sprintf(`New support ticket from %s + +Subject: %s + +%s + +--- +View ticket: %s +`, clientName, subject, body, link) + return m.send(m.cfg.AdminEmail, fmt.Sprintf("[Arcline Portal] New ticket: %s", subject), msg) +} + +// SendTicketReply notifies a party that a reply was added to their ticket. +// toAddr is the recipient; fromName is who replied. +func (m *Mailer) SendTicketReply(toAddr, toName, fromName, ticketSubject, replyBody string, ticketID int64) error { + if toAddr == "" { + return nil + } + link := strings.TrimRight(m.cfg.BaseURL, "/") + fmt.Sprintf("/tickets/%d", ticketID) + msg := fmt.Sprintf(`Hi %s, + +%s replied to your ticket "%s": + +--- +%s +--- + +View the full thread: %s + +— Arcline IT +`, toName, fromName, ticketSubject, replyBody, link) + return m.send(toAddr, fmt.Sprintf("[Arcline Portal] Re: %s", ticketSubject), msg) +} + +// send composes and sends a plain-text email via SMTP STARTTLS. +func (m *Mailer) send(to, subject, body string) error { + addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) + + var buf bytes.Buffer + fmt.Fprintf(&buf, "From: %s\r\n", m.cfg.From) + fmt.Fprintf(&buf, "To: %s\r\n", to) + fmt.Fprintf(&buf, "Subject: %s\r\n", subject) + fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700")) + fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n") + fmt.Fprintf(&buf, "Content-Type: text/plain; charset=UTF-8\r\n") + fmt.Fprintf(&buf, "\r\n") + fmt.Fprintf(&buf, "%s", strings.ReplaceAll(body, "\n", "\r\n")) + + var auth smtp.Auth + if m.cfg.Username != "" { + auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + } + + return smtp.SendMail(addr, auth, m.cfg.From, []string{to}, buf.Bytes()) +} diff --git a/internal/ssl/checker.go b/internal/ssl/checker.go new file mode 100644 index 0000000..bce047a --- /dev/null +++ b/internal/ssl/checker.go @@ -0,0 +1,78 @@ +package ssl + +import ( + "crypto/tls" + "fmt" + "net" + "time" +) + +// Result holds the outcome of a single cert check. +type Result struct { + Domain string + ExpiresAt time.Time + DaysRemaining int + IsValid bool + Error string +} + +// Severity returns a CSS class name for the expiry status. +// +// "ok" — > 30 days +// "warn" — 14–30 days +// "crit" — < 14 days or invalid +func (r Result) Severity() string { + if !r.IsValid { + return "crit" + } + switch { + case r.DaysRemaining > 30: + return "ok" + case r.DaysRemaining >= 14: + return "warn" + default: + return "crit" + } +} + +// Check dials domain:443, retrieves the TLS certificate chain, and returns +// the expiry of the leaf certificate. +func Check(domain string) Result { + r := Result{Domain: domain} + + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 10 * time.Second}, + "tcp", + net.JoinHostPort(domain, "443"), + &tls.Config{ServerName: domain}, + ) + if err != nil { + r.Error = fmt.Sprintf("tls dial: %s", err) + return r + } + defer conn.Close() + + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + r.Error = "no certificates in chain" + return r + } + + leaf := certs[0] + now := time.Now() + + r.ExpiresAt = leaf.NotAfter + r.DaysRemaining = int(leaf.NotAfter.Sub(now).Hours() / 24) + + if now.Before(leaf.NotBefore) { + r.Error = fmt.Sprintf("certificate not yet valid (valid from %s)", leaf.NotBefore.Format("2006-01-02")) + return r + } + if now.After(leaf.NotAfter) { + r.Error = fmt.Sprintf("certificate expired %s", leaf.NotAfter.Format("2006-01-02")) + return r + } + + r.IsValid = true + return r +} diff --git a/internal/uptime/reader.go b/internal/uptime/reader.go new file mode 100644 index 0000000..969ed8a --- /dev/null +++ b/internal/uptime/reader.go @@ -0,0 +1,107 @@ +// Package uptime provides read-only access to arcline-uptime's SQLite database. +// The portal never writes to the uptime DB — it only queries it. +package uptime + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// Reader is a read-only view of the arcline-uptime database. +type Reader struct { + db *sql.DB +} + +// MonitorStatus is a summary of a single monitor's current state. +type MonitorStatus struct { + Name string + Label string // display label from the portal (not from uptime) + Up bool + LastChecked time.Time + ResponseMS int64 + Uptime24h float64 + Uptime7d float64 + Uptime30d float64 +} + +// Open opens the uptime database in read-only mode. +func Open(path string) (*Reader, error) { + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path)) + if err != nil { + return nil, fmt.Errorf("open uptime db: %w", err) + } + db.SetMaxOpenConns(1) + // Verify the expected schema exists. + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checks'`).Scan(&n); err != nil || n == 0 { + db.Close() + return nil, fmt.Errorf("uptime db does not contain a 'checks' table — is the path correct?") + } + return &Reader{db: db}, nil +} + +func (r *Reader) Close() error { return r.db.Close() } + +// GetStatus returns the current status for each of the supplied monitor names. +// Unknown monitors (no check records) are included with Up=false. +func (r *Reader) GetStatus(monitors []string) ([]MonitorStatus, error) { + out := make([]MonitorStatus, 0, len(monitors)) + for _, name := range monitors { + ms := MonitorStatus{Name: name} + + // Latest check + var ts int64 + var up, statusCode int + var responseMS int64 + err := r.db.QueryRow( + `SELECT checked_at, up, status_code, response_ms FROM checks + WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT 1`, + name, + ).Scan(&ts, &up, &statusCode, &responseMS) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + if err != sql.ErrNoRows { + ms.Up = up != 0 + ms.LastChecked = time.Unix(ts, 0) + ms.ResponseMS = responseMS + } + + // Uptime percentages + ms.Uptime24h, _ = r.uptimePct(name, time.Now().Add(-24*time.Hour)) + ms.Uptime7d, _ = r.uptimePct(name, time.Now().Add(-7*24*time.Hour)) + ms.Uptime30d, _ = r.uptimePct(name, time.Now().Add(-30*24*time.Hour)) + + out = append(out, ms) + } + return out, nil +} + +func (r *Reader) uptimePct(monitorName string, since time.Time) (float64, error) { + var total, upCount int64 + err := r.db.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(up), 0) FROM checks WHERE monitor_name = ? AND checked_at >= ?`, + monitorName, since.Unix(), + ).Scan(&total, &upCount) + if err != nil { + return 0, err + } + if total == 0 { + return 100.0, nil + } + return float64(upCount) / float64(total) * 100.0, nil +} + +// Available reports whether the uptime DB can be opened at path. +// Used at startup to warn if the path is wrong without hard-failing. +func Available(path string) bool { + r, err := Open(path) + if err != nil { + return false + } + r.Close() + return true +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..889a266 --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,575 @@ +package web + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "arclineit/arcline-portal/internal/auth" + "arclineit/arcline-portal/internal/db" + "arclineit/arcline-portal/internal/mail" + "arclineit/arcline-portal/internal/ssl" + "arclineit/arcline-portal/internal/uptime" +) + +// Handler holds all HTTP handler dependencies. +type Handler struct { + DB *db.DB + Uptime *uptime.Reader // may be nil if uptime DB unavailable + Mail *mail.Mailer // may be nil if SMTP not configured +} + +// clientFromCtx is a package-local shortcut for auth.ClientFromContext. +func clientFromCtx(r *http.Request) *db.Client { + return auth.ClientFromContext(r.Context()) +} + +func redirect(w http.ResponseWriter, r *http.Request, path string) { + http.Redirect(w, r, path, http.StatusSeeOther) +} + +func redirectFlash(w http.ResponseWriter, r *http.Request, path, msg string) { + http.Redirect(w, r, path+"?flash="+msg, http.StatusSeeOther) +} + +// --- Auth handlers --- + +func (h *Handler) LoginGET(w http.ResponseWriter, r *http.Request) { + render(w, r, "login.html", "Log in — Arcline Portal", nil) +} + +func (h *Handler) LoginPOST(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + + client, err := h.DB.GetClientByUsername(username) + if err != nil || client == nil || !auth.CheckPassword(client.PasswordHash, password) { + render(w, r, "login.html", "Log in — Arcline Portal", map[string]string{ + "Error": "Invalid username or password.", + }) + return + } + + token, err := auth.GenerateToken() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.DB.CreateSession(token, client.ID, time.Now().Add(auth.SessionTTL)); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + auth.SetSessionCookie(w, token) + redirect(w, r, "/dashboard") +} + +func (h *Handler) LogoutPOST(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(auth.SessionCookie); err == nil { + _ = h.DB.DeleteSession(cookie.Value) + } + auth.ClearSessionCookie(w) + redirect(w, r, "/login") +} + +// --- Password reset --- + +func (h *Handler) ForgotGET(w http.ResponseWriter, r *http.Request) { + render(w, r, "forgot.html", "Reset Password — Arcline Portal", nil) +} + +func (h *Handler) ForgotPOST(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + // Always show success to prevent email enumeration. + success := map[string]string{"Success": "If that email is registered, a reset link has been sent."} + + client, err := h.DB.GetClientByEmail(email) + if err != nil || client == nil { + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) + return + } + + token, err := auth.GenerateToken() + if err != nil { + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) + return + } + if err := h.DB.CreatePasswordReset(token, client.ID); err != nil { + slog.Error("create password reset", "err", err) + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) + return + } + if h.Mail != nil && h.Mail.Configured() { + if err := h.Mail.SendPasswordReset(client.Email, client.DisplayName, token); err != nil { + slog.Error("send password reset email", "err", err) + } + } + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) +} + +func (h *Handler) ResetGET(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + redirect(w, r, "/forgot") + return + } + render(w, r, "reset.html", "Set New Password — Arcline Portal", map[string]string{"Token": token}) +} + +func (h *Handler) ResetPOST(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.FormValue("token")) + password := r.FormValue("password") + confirm := r.FormValue("confirm") + + errData := func(msg string) { + render(w, r, "reset.html", "Set New Password — Arcline Portal", map[string]string{ + "Token": token, + "Error": msg, + }) + } + + if token == "" { + redirect(w, r, "/forgot") + return + } + if len(password) < 8 { + errData("Password must be at least 8 characters.") + return + } + if password != confirm { + errData("Passwords do not match.") + return + } + + clientID, err := h.DB.UsePasswordReset(token) + if err != nil { + errData("Reset link is invalid or has expired.") + return + } + + hash, err := auth.HashPassword(password) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.DB.UpdateClientPassword(clientID, hash); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + redirectFlash(w, r, "/login", "Password+updated.+Please+log+in.") +} + +// --- Settings --- + +func (h *Handler) SettingsGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + }) +} + +func (h *Handler) SettingsEmailPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + if email == "" { + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + "Error": "Email cannot be empty.", + }) + return + } + if err := h.DB.UpdateClientEmail(client.ID, email); err != nil { + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + "Error": "Failed to update email.", + }) + return + } + redirectFlash(w, r, "/settings", "Email+updated.") +} + +func (h *Handler) SettingsPasswordPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + current := r.FormValue("current") + newPass := r.FormValue("password") + confirm := r.FormValue("confirm") + + errData := func(msg string) { + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + "Error": msg, + }) + } + + if !auth.CheckPassword(client.PasswordHash, current) { + errData("Current password is incorrect.") + return + } + if len(newPass) < 8 { + errData("New password must be at least 8 characters.") + return + } + if newPass != confirm { + errData("Passwords do not match.") + return + } + hash, err := auth.HashPassword(newPass) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.DB.UpdateClientPassword(client.ID, hash); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + redirectFlash(w, r, "/settings", "Password+changed.") +} + +// --- Dashboard --- + +type dashboardData struct { + Monitors []uptime.MonitorStatus + Domains []db.Domain + Tickets []db.Ticket +} + +func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + + dbMonitors, err := h.DB.ListMonitors(client.ID) + if err != nil { + slog.Error("list monitors", "err", err) + } + + var statuses []uptime.MonitorStatus + if h.Uptime != nil && len(dbMonitors) > 0 { + names := make([]string, len(dbMonitors)) + labels := make(map[string]string, len(dbMonitors)) + for i, m := range dbMonitors { + names[i] = m.MonitorName + labels[m.MonitorName] = m.Label + } + statuses, err = h.Uptime.GetStatus(names) + if err != nil { + slog.Error("get uptime status", "err", err) + } + for i := range statuses { + if l, ok := labels[statuses[i].Name]; ok && l != "" { + statuses[i].Label = l + } else { + statuses[i].Label = statuses[i].Name + } + } + } + + domains, _ := h.DB.ListDomains(client.ID) + tickets, _ := h.DB.ListTickets(client.ID) + + render(w, r, "dashboard.html", "Dashboard — Arcline Portal", dashboardData{ + Monitors: statuses, + Domains: domains, + Tickets: tickets, + }) +} + +// --- SSL --- + +func (h *Handler) SSLGet(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + domains, _ := h.DB.ListDomains(client.ID) + render(w, r, "ssl.html", "SSL Certificates — Arcline Portal", domains) +} + +func (h *Handler) SSLAddPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain"))) + if domain == "" { + redirectFlash(w, r, "/ssl", "Domain+cannot+be+empty.") + return + } + // Strip scheme if pasted in + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimPrefix(domain, "http://") + domain = strings.TrimSuffix(domain, "/") + + if err := h.DB.AddDomain(client.ID, domain); err != nil { + redirectFlash(w, r, "/ssl", "Failed+to+add+domain.") + return + } + // Kick off an immediate check in the background. + go func() { + domains, err := h.DB.ListDomains(client.ID) + if err != nil { + return + } + for _, d := range domains { + if d.Domain == domain { + res := ssl.Check(d.Domain) + _ = h.DB.UpdateDomainStatus(d.ID, res.ExpiresAt, res.DaysRemaining, res.IsValid, res.Error) + break + } + } + }() + redirect(w, r, "/ssl") +} + +func (h *Handler) SSLDeletePOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { + redirect(w, r, "/ssl") + return + } + // Verify the domain belongs to this client before deleting. + domains, _ := h.DB.ListDomains(client.ID) + for _, d := range domains { + if d.ID == id { + _ = h.DB.RemoveDomain(id) + break + } + } + redirect(w, r, "/ssl") +} + +// --- Tickets --- + +func (h *Handler) TicketsGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + tickets, _ := h.DB.ListTickets(client.ID) + render(w, r, "tickets.html", "Support Tickets — Arcline Portal", tickets) +} + +func (h *Handler) TicketNewPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + subject := strings.TrimSpace(r.FormValue("subject")) + body := strings.TrimSpace(r.FormValue("body")) + if subject == "" || body == "" { + redirectFlash(w, r, "/tickets", "Subject+and+message+are+required.") + return + } + ticket, err := h.DB.CreateTicket(client.ID, subject, body) + if err != nil { + slog.Error("create ticket", "err", err) + redirectFlash(w, r, "/tickets", "Failed+to+create+ticket.") + return + } + // Notify admin of new ticket. + if h.Mail != nil && h.Mail.Configured() { + go func() { + if err := h.Mail.SendTicketCreated(client.DisplayName, subject, body, ticket.ID); err != nil { + slog.Error("send ticket created email", "err", err) + } + }() + } + redirect(w, r, fmt.Sprintf("/tickets/%d", ticket.ID)) +} + +type ticketDetailData struct { + Ticket *db.Ticket + Messages []db.TicketMessage +} + +func (h *Handler) TicketGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + ticket, err := h.DB.GetTicket(id) + if err != nil || ticket == nil || (!client.IsAdmin && ticket.ClientID != client.ID) { + http.NotFound(w, r) + return + } + messages, _ := h.DB.GetTicketMessages(id) + render(w, r, "ticket.html", ticket.Subject+" — Arcline Portal", ticketDetailData{ + Ticket: ticket, + Messages: messages, + }) +} + +func (h *Handler) TicketReplyPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + ticket, err := h.DB.GetTicket(id) + if err != nil || ticket == nil || (!client.IsAdmin && ticket.ClientID != client.ID) { + http.NotFound(w, r) + return + } + body := strings.TrimSpace(r.FormValue("body")) + if body == "" { + redirect(w, r, fmt.Sprintf("/tickets/%d", id)) + return + } + _ = h.DB.AddTicketMessage(id, body, client.IsAdmin) + + // Close ticket if admin checked the close box. + if client.IsAdmin && r.FormValue("close") == "1" { + _ = h.DB.SetTicketStatus(id, db.TicketClosed) + } + + // Email notifications for replies. + if h.Mail != nil && h.Mail.Configured() { + go func() { + ticketOwner, err := h.DB.GetClientByID(ticket.ClientID) + if err != nil || ticketOwner == nil { + return + } + if client.IsAdmin { + // Admin replied — notify the ticket owner. + if ticketOwner.Email != "" { + if err := h.Mail.SendTicketReply( + ticketOwner.Email, ticketOwner.DisplayName, + client.DisplayName, ticket.Subject, body, id, + ); err != nil { + slog.Error("send ticket reply email to client", "err", err) + } + } + } else { + // Client replied — notify admin via SendTicketCreated re-use pattern. + if err := h.Mail.SendTicketCreated(client.DisplayName, + "Re: "+ticket.Subject, body, id); err != nil { + slog.Error("send ticket reply email to admin", "err", err) + } + } + }() + } + + redirect(w, r, fmt.Sprintf("/tickets/%d", id)) +} + +// --- Admin --- + +type adminIndexData struct { + Clients []db.Client + Tickets []db.Ticket +} + +func (h *Handler) AdminIndexGET(w http.ResponseWriter, r *http.Request) { + clients, _ := h.DB.ListClients() + tickets, _ := h.DB.ListAllTickets() + render(w, r, "admin/index.html", "Admin — Arcline Portal", adminIndexData{ + Clients: clients, + Tickets: tickets, + }) +} + +func (h *Handler) AdminClientNewPOST(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + displayName := strings.TrimSpace(r.FormValue("display_name")) + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + password := r.FormValue("password") + isAdmin := r.FormValue("is_admin") == "1" + + if username == "" || displayName == "" || len(password) < 8 { + redirectFlash(w, r, "/admin", "Username,+display+name+required.+Password+min+8+chars.") + return + } + hash, err := auth.HashPassword(password) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if _, err := h.DB.CreateClient(username, displayName, email, hash, isAdmin); err != nil { + redirectFlash(w, r, "/admin", "Failed+to+create+client+(username+may+be+taken).") + return + } + redirect(w, r, "/admin") +} + +type adminClientData struct { + Client *db.Client + Monitors []db.Monitor + Domains []db.Domain +} + +func (h *Handler) AdminClientGET(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + client, err := h.DB.GetClientByID(id) + if err != nil || client == nil { + http.NotFound(w, r) + return + } + monitors, _ := h.DB.ListMonitors(id) + domains, _ := h.DB.ListDomains(id) + render(w, r, "admin/client.html", client.DisplayName+" — Admin", adminClientData{ + Client: client, + Monitors: monitors, + Domains: domains, + }) +} + +func (h *Handler) AdminMonitorAddPOST(w http.ResponseWriter, r *http.Request) { + clientID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + monitorName := strings.TrimSpace(r.FormValue("monitor_name")) + label := strings.TrimSpace(r.FormValue("label")) + if monitorName == "" { + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) + return + } + _ = h.DB.AddMonitor(clientID, monitorName, label) + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) +} + +func (h *Handler) AdminMonitorDeletePOST(w http.ResponseWriter, r *http.Request) { + clientID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + monitorID, err := strconv.ParseInt(r.FormValue("monitor_id"), 10, 64) + if err != nil { + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) + return + } + _ = h.DB.RemoveMonitor(monitorID) + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) +} + +func (h *Handler) AdminClientDeletePOST(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + _ = h.DB.DeleteClient(id) + redirect(w, r, "/admin") +} + +// --- 404 --- + +func (h *Handler) NotFoundHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + render(w, r, "404.html", "Not Found — Arcline Portal", nil) +} + +// RunSSLChecker runs a full pass of cert checks against all domains in the DB. +// Call this from a background goroutine on a daily ticker. +func (h *Handler) RunSSLChecker() { + domains, err := h.DB.AllDomainsForCheck() + if err != nil { + slog.Error("ssl checker: list domains", "err", err) + return + } + for _, d := range domains { + res := ssl.Check(d.Domain) + if err := h.DB.UpdateDomainStatus(d.ID, res.ExpiresAt, res.DaysRemaining, res.IsValid, res.Error); err != nil { + slog.Error("ssl checker: update domain", "domain", d.Domain, "err", err) + } + } + slog.Info("ssl checker: completed", "domains", len(domains)) +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..1197cf4 --- /dev/null +++ b/internal/web/render.go @@ -0,0 +1,74 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "net/http" + "strings" + "time" +) + +//go:embed templates +var templateFS embed.FS + +var funcMap = template.FuncMap{ + "upper": strings.ToUpper, + "lower": strings.ToLower, + "formatDate": func(t time.Time) string { return t.Format("2006-01-02") }, + "formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, + "ago": func(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } + }, + "pct": func(f float64) string { return fmt.Sprintf("%.2f%%", f) }, +} + +// parse returns a template set containing base.html and the named page. +// Parsing per-request ensures each page's {{define "content"}} is isolated. +func parse(name string) (*template.Template, error) { + return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/base.html", "templates/"+name) +} + +type pageData struct { + Title string + Username string + IsAdmin bool + Flash string + Path string + Data any +} + +func render(w http.ResponseWriter, r *http.Request, name string, title string, data any) { + pd := pageData{ + Title: title, + Path: r.URL.Path, + Data: data, + } + // Inject client info from context if present. + if c := clientFromCtx(r); c != nil { + pd.Username = c.DisplayName + pd.IsAdmin = c.IsAdmin + } + // Flash message from query param (redirect-after-post pattern). + pd.Flash = r.URL.Query().Get("flash") + + t, err := parse(name) + if err != nil { + http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "base", pd); err != nil { + http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/web/templates/404.html b/internal/web/templates/404.html new file mode 100644 index 0000000..35b8f8c --- /dev/null +++ b/internal/web/templates/404.html @@ -0,0 +1,22 @@ +{{define "content"}} +
$ find / -name "{{"{{"}}/* path not found */}}"
+404
++ That page doesn't exist. +
+ ← back to dashboard ++ Monitor names must match exactly what's configured in arcline-uptime. +
+ + + + {{if .Monitors}} +| monitor name | label | |
|---|---|---|
| {{.MonitorName}} | +{{if .Label}}{{.Label}}{{else}}—{{end}} | ++ + | +
No monitors assigned.
+ {{end}} +| domain | expires | days | status |
|---|---|---|---|
| {{.Domain}} | +{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}} | +{{if .IsValid}}{{.DaysRemaining}}d{{else}}—{{end}} | ++ {{if .IsValid}} + {{if gt .DaysRemaining 30}}OK + {{else if ge .DaysRemaining 14}}EXPIRING + {{else}}CRITICAL{{end}} + {{else if .CheckError}}ERROR + {{else}}PENDING{{end}} + | +
No domains tracked for this client.
+ {{end}} +admin
+| username | display name | role | joined | |
|---|---|---|---|---|
| {{.Username}} | +{{.DisplayName}} | +{{if .IsAdmin}}admin{{else}}client{{end}} | +{{formatDate .CreatedAt}} | ++ + | +
No clients yet.
+ {{end}} +| # | subject | client | status | updated |
|---|---|---|---|---|
| #{{.ID}} | +{{.Subject}} | +{{.ClientName}} | +{{.Status}} | +{{ago .UpdatedAt}} | +
No tickets.
+ {{end}} +overview
+No services configured. Contact support to get services added to your account.
+ {{end}} +Add a domain to track SSL expiry.
+ {{end}} +| # | subject | status | updated |
|---|---|---|---|
| #{{.ID}} | +{{.Subject}} | +{{.Status}} | +{{ago .UpdatedAt}} | +
No tickets. Open one if you need help.
+ {{end}} +Enter the email address on your account and we'll send a reset link.
+ + {{with .Data}}{{if .Error}}{{.Error}}
{{end}} + {{if .Success}}{{.Success}}
{{end}}{{end}} + + ++ ← back to login +
+$ ssh client@portal.arclineit.com
+ + {{if .Data}}{{with .Data}} + {{if .Error}}{{.Error}}
{{end}} + {{end}}{{end}} + + ++ forgot password? +
+{{.Error}}
{{end}}{{end}} + + +account
+{{.Error}}
{{end}}{{end}} + +Email Address
+ + +Change Password
+ + +monitoring
+Cert expiry is checked daily. Add a domain to start tracking.
+| domain | +status | +expires | +days | +last checked | ++ |
|---|---|---|---|---|---|
| {{.Domain}} | ++ {{if .IsValid}} + {{if gt .DaysRemaining 30}}OK + {{else if ge .DaysRemaining 14}}EXPIRING + {{else}}CRITICAL + {{end}} + {{else if .CheckError}} + ERROR + {{else}} + PENDING + {{end}} + | +{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}} | ++ {{if .IsValid}}{{.DaysRemaining}}d + {{else if .CheckError}}error + {{else}}—{{end}} + | ++ {{if .LastCheckedAt.IsZero}}never{{else}}{{ago .LastCheckedAt}}{{end}} + | ++ + | +
No domains yet. Add one above to start tracking SSL expiry.
+ {{end}} + {{end}} +This ticket is closed.
+ {{end}} +support
+| # | subject | status | updated |
|---|---|---|---|
| #{{.ID}} | +{{.Subject}} | +{{.Status}} | +{{ago .UpdatedAt}} | +
No tickets yet.
+ {{end}} + {{end}} +