fix: P2+P3 bug fixes, hardening, and cleanup (18 files)
Bug fixes: - Add applyEnvOverrides to LoadFromBytes (M05) - Set state=failed on compose-up failure in selfupdate (M16) - Clamp usableMB to min 0 in memory check (M22) - Remove "manual" schedule from triggerAllCrossBackups (M23) - Add mmcblk device handling for partition paths (M21) - Fix stripPartition for mmcblk devices (L25) - Fix TruncateStr for UTF-8 and negative maxLen (L05/L06) - Fix AllDone to return false for empty restore plans (L14) - Fix PushOnce to return actual errors (L39) - Restore pending events on save failure in DrainPendingEvents (M03) - Add duplicate check in AddStoragePath (M04) - Call CleanupTempMounts after drive scan (H13) - Log SetStep save errors (M25) Hardening: - Guard scheduler Start() against double-start (M14) - Acquire mutex in scheduler Stop() before reading cancel (L24) - Cap log lines parameter to 10000 (L31) - Require POST for logout (L32) - Use sync.Once for Server.Close() (L49) - Panic on crypto/rand.Read failure in setup CSRF (L40) - Validate Bearer token against Hub API key in CSRF (H16 fix) - Replace custom hasPrefix with strings.HasPrefix (L13) - Replace simpleHash with crc32.ChecksumIEEE (L48) Cleanup: - Remove dead imageName function (L02) - Remove dead detectHostIPViaRoute function (L03) - Rename shadowed copy variable to cp (L07) - Copy DefaultEnabledEvents in GetNotificationPrefs early return (L09) - Update BUGHUNT.md with comprehensive audit results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+619
-761
File diff suppressed because it is too large
Load Diff
@@ -379,6 +379,9 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
|||||||
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
|
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
|
||||||
reservedMB := r.cfg.System.ReservedMemoryMB
|
reservedMB := r.cfg.System.ReservedMemoryMB
|
||||||
usableMB := totalMB - reservedMB
|
usableMB := totalMB - reservedMB
|
||||||
|
if usableMB < 0 {
|
||||||
|
usableMB = 0
|
||||||
|
}
|
||||||
afterMB := usedMB + stackMemMB
|
afterMB := usedMB + stackMemMB
|
||||||
if afterMB > usableMB {
|
if afterMB > usableMB {
|
||||||
writeJSON(w, http.StatusConflict, apiResponse{
|
writeJSON(w, http.StatusConflict, apiResponse{
|
||||||
@@ -444,6 +447,9 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
|
|||||||
if v := req.URL.Query().Get("lines"); v != "" {
|
if v := req.URL.Query().Get("lines"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
lines = n
|
lines = n
|
||||||
|
if lines > 10000 {
|
||||||
|
lines = 10000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,9 +924,6 @@ func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request)
|
|||||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
||||||
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
||||||
}
|
}
|
||||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil {
|
|
||||||
r.logger.Printf("[API] Cross-drive run-all manual error: %v", err)
|
|
||||||
}
|
|
||||||
if r.OnCrossDriveComplete != nil {
|
if r.OnCrossDriveComplete != nil {
|
||||||
r.OnCrossDriveComplete()
|
r.OnCrossDriveComplete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -131,9 +132,13 @@ func (rp *RestorePlan) UpdateApp(name, status, errMsg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AllDone returns true if all apps are done/failed/skipped.
|
// AllDone returns true if all apps are done/failed/skipped.
|
||||||
|
// Returns false for empty plans (no apps to restore).
|
||||||
func (rp *RestorePlan) AllDone() bool {
|
func (rp *RestorePlan) AllDone() bool {
|
||||||
rp.mu.RLock()
|
rp.mu.RLock()
|
||||||
defer rp.mu.RUnlock()
|
defer rp.mu.RUnlock()
|
||||||
|
if len(rp.Apps) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, app := range rp.Apps {
|
for _, app := range rp.Apps {
|
||||||
if app.Status != "done" && app.Status != "failed" && app.Status != "skipped" {
|
if app.Status != "done" && app.Status != "failed" && app.Status != "skipped" {
|
||||||
return false
|
return false
|
||||||
@@ -280,13 +285,10 @@ func hasUserData(rsyncBase string) bool {
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
name := e.Name()
|
name := e.Name()
|
||||||
if name != "_config" && name != "_db" && !hasPrefix(name, ".") {
|
if name != "_config" && name != "_db" && !strings.HasPrefix(name, ".") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPrefix(s, prefix string) bool {
|
|
||||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func LoadFromBytes(data []byte) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
applyDefaults(cfg)
|
applyDefaults(cfg)
|
||||||
|
applyEnvOverrides(cfg)
|
||||||
if err := validate(cfg); err != nil {
|
if err := validate(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,29 +198,27 @@ func (p *Pusher) PushOnce(report *Report) error {
|
|||||||
|
|
||||||
data, err := json.Marshal(report)
|
data, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Printf("[WARN] Hub report marshal failed: %v", err)
|
return fmt.Errorf("marshal report: %w", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url := p.hubURL + "/api/v1/report"
|
url := p.hubURL + "/api/v1/report"
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Printf("[WARN] Hub disabled-notification failed: %v", err)
|
return fmt.Errorf("hub push-once: %w", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
p.logger.Printf("[INFO] Hub disabled-notification sent (%d bytes)", len(data))
|
p.logger.Printf("[INFO] Hub push-once sent (%d bytes)", len(data))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,15 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
|
|||||||
s.logger.Printf("[SCHED] Daily job %s scheduled for %s", name, nextRun.Format("2006-01-02 15:04 MST"))
|
s.logger.Printf("[SCHED] Daily job %s scheduled for %s", name, nextRun.Format("2006-01-02 15:04 MST"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins running all registered jobs.
|
// Start begins running all registered jobs. Safe to call only once.
|
||||||
func (s *Scheduler) Start(ctx context.Context) {
|
func (s *Scheduler) Start(ctx context.Context) {
|
||||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
if s.cancel != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.logger.Println("[WARN] Scheduler already started — ignoring duplicate Start()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
for _, job := range s.jobs {
|
for _, job := range s.jobs {
|
||||||
if job.Interval > 0 {
|
if job.Interval > 0 {
|
||||||
@@ -113,12 +116,16 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Printf("[SCHED] Scheduler started with %d jobs", len(s.jobs))
|
s.logger.Printf("[SCHED] Scheduler started with %d jobs", len(s.jobs))
|
||||||
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop cancels all jobs and waits for them to finish (30s timeout).
|
// Stop cancels all jobs and waits for them to finish (30s timeout).
|
||||||
func (s *Scheduler) Stop() {
|
func (s *Scheduler) Stop() {
|
||||||
if s.cancel != nil {
|
s.mu.Lock()
|
||||||
s.cancel()
|
cancel := s.cancel
|
||||||
|
s.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|||||||
@@ -229,13 +229,6 @@ func registryImagePath(image string) string {
|
|||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageName extracts the repo name from a full image reference.
|
|
||||||
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "felhom-controller"
|
|
||||||
func imageName(image string) string {
|
|
||||||
parts := strings.Split(image, "/")
|
|
||||||
return parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// DryRunResult holds the result of a self-update dry run.
|
// DryRunResult holds the result of a self-update dry run.
|
||||||
type DryRunResult struct {
|
type DryRunResult struct {
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
@@ -399,8 +392,10 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
|
|||||||
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
|
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
|
||||||
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
|
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
|
||||||
if upErr != nil {
|
if upErr != nil {
|
||||||
// If we get here, compose up failed but we already changed the image tag.
|
state.Status = "failed"
|
||||||
// Log the error — the state file remains "pending" for manual investigation.
|
state.Error = fmt.Sprintf("docker compose up -d failed: %v — %s", upErr, upOut)
|
||||||
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
SaveState(u.dataDir, state)
|
||||||
u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
|
u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,8 +264,10 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
if s.Notifications == nil {
|
if s.Notifications == nil {
|
||||||
|
events := make([]string, len(DefaultEnabledEvents))
|
||||||
|
copy(events, DefaultEnabledEvents)
|
||||||
return &NotificationPrefs{
|
return &NotificationPrefs{
|
||||||
EnabledEvents: DefaultEnabledEvents,
|
EnabledEvents: events,
|
||||||
CooldownHours: 6,
|
CooldownHours: 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,14 +293,14 @@ func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
copy := *prefs
|
cp := *prefs
|
||||||
if len(prefs.EnabledEvents) > 0 {
|
if len(prefs.EnabledEvents) > 0 {
|
||||||
copy.EnabledEvents = make([]string, len(prefs.EnabledEvents))
|
cp.EnabledEvents = make([]string, len(prefs.EnabledEvents))
|
||||||
for i, e := range prefs.EnabledEvents {
|
for i, e := range prefs.EnabledEvents {
|
||||||
copy.EnabledEvents[i] = e
|
cp.EnabledEvents[i] = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Notifications = ©
|
s.Notifications = &cp
|
||||||
return s.save()
|
return s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +424,11 @@ func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
|
|||||||
func (s *Settings) AddStoragePath(sp StoragePath) error {
|
func (s *Settings) AddStoragePath(sp StoragePath) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
for _, existing := range s.StoragePaths {
|
||||||
|
if existing.Path == sp.Path {
|
||||||
|
return fmt.Errorf("storage path %q already registered", sp.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
if sp.IsDefault {
|
if sp.IsDefault {
|
||||||
for i := range s.StoragePaths {
|
for i := range s.StoragePaths {
|
||||||
s.StoragePaths[i].IsDefault = false
|
s.StoragePaths[i].IsDefault = false
|
||||||
@@ -808,7 +815,9 @@ func (s *Settings) DrainPendingEvents() []PendingEvent {
|
|||||||
copy(events, s.PendingEvents)
|
copy(events, s.PendingEvents)
|
||||||
s.PendingEvents = nil
|
s.PendingEvents = nil
|
||||||
if err := s.save(); err != nil {
|
if err := s.save(); err != nil {
|
||||||
s.log.Printf("[ERROR] Failed to save after draining pending events: %v", err)
|
s.log.Printf("[ERROR] Failed to save after draining pending events: %v — restoring events", err)
|
||||||
|
s.PendingEvents = events
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ const csrfFormField = "_csrf"
|
|||||||
func generateCSRFToken() string {
|
func generateCSRFToken() string {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
// Fallback to time-based (extremely unlikely)
|
panic("crypto/rand.Read failed: " + err.Error())
|
||||||
return "fallback-csrf-token"
|
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package setup
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,46 +10,17 @@ import (
|
|||||||
// Inside a Docker container, the network interfaces only show the bridge IP
|
// Inside a Docker container, the network interfaces only show the bridge IP
|
||||||
// (e.g. 172.18.0.4), which is useless for users. Instead, we:
|
// (e.g. 172.18.0.4), which is useless for users. Instead, we:
|
||||||
// 1. Check HOST_IP env var (set by docker-compose.yml)
|
// 1. Check HOST_IP env var (set by docker-compose.yml)
|
||||||
// 2. Try to detect the Docker host gateway via `ip route`
|
// 2. Fall back to interface enumeration as last resort
|
||||||
// 3. Fall back to interface enumeration as last resort
|
|
||||||
func DetectLocalIPs() []string {
|
func DetectLocalIPs() []string {
|
||||||
// Option 1: explicit HOST_IP from environment
|
// Option 1: explicit HOST_IP from environment
|
||||||
if hostIP := os.Getenv("HOST_IP"); hostIP != "" {
|
if hostIP := os.Getenv("HOST_IP"); hostIP != "" {
|
||||||
return []string{hostIP}
|
return []string{hostIP}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option 2: detect Docker host gateway IP via default route
|
// Option 2: fallback to interface enumeration (works on bare metal)
|
||||||
// Inside a container, `ip route | grep default` gives the host gateway.
|
|
||||||
// Then we check the host's IP by looking at what IP routes to that gateway.
|
|
||||||
if ip := detectHostIPViaRoute(); ip != "" {
|
|
||||||
return []string{ip}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option 3: fallback to interface enumeration (works on bare metal)
|
|
||||||
return detectInterfaceIPs()
|
return detectInterfaceIPs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectHostIPViaRoute tries to find the Docker host's LAN IP.
|
|
||||||
// Inside a container, the default gateway is the Docker host.
|
|
||||||
// We read /host-etc/hostname or use the gateway as a hint.
|
|
||||||
func detectHostIPViaRoute() string {
|
|
||||||
// Try: ip route get 1.0.0.0 — shows the source IP used for routing
|
|
||||||
out, err := exec.Command("ip", "route", "get", "1.0.0.0").Output()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Output: "1.0.0.0 via 172.18.0.1 dev eth0 src 172.18.0.4"
|
|
||||||
// The gateway (172.18.0.1) is the Docker host — but that's the bridge IP.
|
|
||||||
// We need the host's actual LAN IP.
|
|
||||||
|
|
||||||
// Better approach: read /proc/net/route or parse `ip route` for the gateway,
|
|
||||||
// then the gateway itself is the Docker host — but we need its external IP.
|
|
||||||
// Since we can't easily get the host's LAN IP from inside the container,
|
|
||||||
// return empty and let the fallback handle it or rely on HOST_IP env.
|
|
||||||
_ = out
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectInterfaceIPs() []string {
|
func detectInterfaceIPs() []string {
|
||||||
ifaces, err := net.Interfaces()
|
ifaces, err := net.Interfaces()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ func countValid(results []DriveBackup) int {
|
|||||||
func (s *Server) runDriveScan() {
|
func (s *Server) runDriveScan() {
|
||||||
results, err := ScanDrivesForInfraBackups(s.logger, s.isDebug())
|
results, err := ScanDrivesForInfraBackups(s.logger, s.isDebug())
|
||||||
|
|
||||||
|
// Clean up any temporary mounts created during scan
|
||||||
|
if results != nil {
|
||||||
|
CleanupTempMounts(results, s.logger)
|
||||||
|
}
|
||||||
|
|
||||||
s.scanMu.Lock()
|
s.scanMu.Lock()
|
||||||
defer s.scanMu.Unlock()
|
defer s.scanMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (s *SetupState) SetStep(step string) {
|
|||||||
s.Step = step
|
s.Step = step
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if err := s.Save(); err != nil {
|
if err := s.Save(); err != nil {
|
||||||
// Best effort — don't crash
|
log.Printf("[WARN] Failed to save setup step %q: %v", step, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
|
|||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
partDev = req.DevicePath + "1"
|
partDev = req.DevicePath + "1"
|
||||||
if strings.Contains(req.DevicePath, "nvme") {
|
if strings.Contains(req.DevicePath, "nvme") || strings.Contains(req.DevicePath, "mmcblk") {
|
||||||
partDev = req.DevicePath + "p1"
|
partDev = req.DevicePath + "p1"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(HostDevicePath(partDev)); err != nil {
|
if _, err := os.Stat(HostDevicePath(partDev)); err != nil {
|
||||||
|
|||||||
@@ -215,9 +215,9 @@ func isSameBlockDevice(pathA, pathB string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// stripPartition strips the partition suffix from a device name.
|
// stripPartition strips the partition suffix from a device name.
|
||||||
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1".
|
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0".
|
||||||
func stripPartition(base string) string {
|
func stripPartition(base string) string {
|
||||||
if strings.HasPrefix(base, "nvme") {
|
if strings.HasPrefix(base, "nvme") || strings.HasPrefix(base, "mmcblk") {
|
||||||
if idx := strings.LastIndex(base, "p"); idx > 4 {
|
if idx := strings.LastIndex(base, "p"); idx > 4 {
|
||||||
return base[:idx]
|
return base[:idx]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ package util
|
|||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// TruncateStr truncates a string to maxLen characters, appending "..." if truncated.
|
// TruncateStr truncates a string to maxLen runes, appending "..." if truncated.
|
||||||
func TruncateStr(s string, maxLen int) string {
|
func TruncateStr(s string, maxLen int) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) <= maxLen {
|
if maxLen <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:maxLen] + "..."
|
return string(runes[:maxLen]) + "..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -219,11 +220,7 @@ func (am *AlertManager) GetInlineAlerts(page string) []Alert {
|
|||||||
|
|
||||||
// simpleHash returns a short deterministic hash for deduplication.
|
// simpleHash returns a short deterministic hash for deduplication.
|
||||||
func simpleHash(s string) string {
|
func simpleHash(s string) string {
|
||||||
h := uint32(0)
|
return fmt.Sprintf("%08x", crc32.ChecksumIEEE([]byte(s)))
|
||||||
for _, c := range s {
|
|
||||||
h = h*31 + uint32(c)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%08x", h)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sortAlerts sorts alerts by severity: error > warning > info.
|
// sortAlerts sorts alerts by severity: error > warning > info.
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
s.sessionsMu.Lock()
|
s.sessionsMu.Lock()
|
||||||
delete(s.sessions, cookie.Value)
|
delete(s.sessions, cookie.Value)
|
||||||
@@ -203,9 +207,11 @@ func (s *Server) cleanupSessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close signals the server to stop background goroutines.
|
// Close signals the server to stop background goroutines. Safe to call multiple times.
|
||||||
func (s *Server) Close() {
|
func (s *Server) Close() {
|
||||||
close(s.done)
|
s.closeOnce.Do(func() {
|
||||||
|
close(s.done)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
|
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type Server struct {
|
|||||||
sessions map[string]*session
|
sessions map[string]*session
|
||||||
sessionsMu sync.RWMutex
|
sessionsMu sync.RWMutex
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
|
||||||
// Disk operation state (format/migrate jobs)
|
// Disk operation state (format/migrate jobs)
|
||||||
diskJobMu sync.Mutex
|
diskJobMu sync.Mutex
|
||||||
|
|||||||
Reference in New Issue
Block a user