diff --git a/dashboard/css/panels.css b/dashboard/css/panels.css
index a0d38b0..0208029 100644
--- a/dashboard/css/panels.css
+++ b/dashboard/css/panels.css
@@ -692,6 +692,542 @@
font-size: 14px;
}
+/* ----- Security Panel Components ----- */
+
+/* Security Status Indicator */
+#security-status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border-radius: 20px;
+ cursor: pointer;
+ transition: background 0.2s;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+#security-status-indicator:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.security-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #666;
+ box-shadow: 0 0 8px currentColor;
+}
+
+.security-status-indicator.mode-disarmed .security-status-dot {
+ background: #888;
+ box-shadow: 0 0 8px rgba(136, 136, 136, 0.5);
+}
+
+.security-status-indicator.mode-learning .security-status-dot {
+ background: #ffa726;
+ box-shadow: 0 0 8px rgba(255, 167, 38, 0.5);
+}
+
+.security-status-indicator.mode-armed .security-status-dot {
+ background: #66bb6a;
+ box-shadow: 0 0 8px rgba(102, 187, 106, 0.5);
+}
+
+.security-status-indicator.mode-alert .security-status-dot {
+ background: #ef5350;
+ box-shadow: 0 0 8px rgba(239, 83, 80, 0.5);
+ animation: security-pulse 1s ease-in-out infinite;
+}
+
+@keyframes security-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.security-status-text {
+ color: #ccc;
+}
+
+.security-status-indicator.mode-alert .security-status-text {
+ color: #ef5350;
+}
+
+/* Security Dialog Overlay */
+#security-dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0);
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.3s ease;
+ pointer-events: none;
+}
+
+#security-dialog-overlay.visible {
+ background: rgba(0, 0, 0, 0.7);
+ pointer-events: auto;
+}
+
+#security-dialog {
+ background: #1e1e3a;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ width: 480px;
+ max-width: 90vw;
+ padding: 24px;
+ opacity: 0;
+ transform: scale(0.95);
+ transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+#security-dialog-overlay.visible #security-dialog {
+ opacity: 1;
+ transform: scale(1);
+}
+
+#security-dialog h2 {
+ margin: 0 0 16px;
+ font-size: 20px;
+ font-weight: 600;
+ color: #eee;
+}
+
+#security-dialog p {
+ margin: 0 0 20px;
+ font-size: 14px;
+ color: #ccc;
+ line-height: 1.5;
+}
+
+#security-dialog .learning-warning {
+ background: rgba(255, 167, 38, 0.15);
+ border: 1px solid rgba(255, 167, 38, 0.3);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 20px;
+ font-size: 13px;
+ color: #ffa726;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+#security-dialog .learning-warning::before {
+ content: '⚠';
+ font-size: 18px;
+}
+
+#security-dialog-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+/* Alert Banner */
+#alert-banner {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(135deg, rgba(239, 83, 80, 0.95), rgba(244, 67, 54, 0.95));
+ color: white;
+ padding: 16px 20px;
+ z-index: 5000;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ box-shadow: 0 4px 20px rgba(239, 83, 80, 0.4);
+ transform: translateY(-100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+#alert-banner.visible {
+ transform: translateY(0);
+}
+
+.alert-banner-content {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.alert-banner-icon {
+ font-size: 24px;
+ animation: alert-shake 0.5s ease-in-out infinite;
+}
+
+@keyframes alert-shake {
+ 0%, 100% { transform: rotate(0deg); }
+ 25% { transform: rotate(-5deg); }
+ 75% { transform: rotate(5deg); }
+}
+
+.alert-banner-message {
+ flex: 1;
+}
+
+.alert-banner-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.alert-banner-subtitle {
+ font-size: 13px;
+ opacity: 0.9;
+}
+
+.alert-banner-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.alert-banner-btn {
+ padding: 10px 16px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ border: none;
+ transition: background 0.2s;
+}
+
+.alert-banner-btn-acknowledge {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+}
+
+.alert-banner-btn-acknowledge:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.alert-banner-btn-view {
+ background: white;
+ color: #ef5350;
+}
+
+.alert-banner-btn-view:hover {
+ background: rgba(255, 255, 255, 0.9);
+}
+
+/* Security Card Panel Content */
+.security-card-section {
+ margin-bottom: 24px;
+}
+
+.security-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.security-card-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.security-status-display {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ margin-bottom: 16px;
+}
+
+.security-status-badge {
+ flex: 1;
+}
+
+.security-status-badge-label {
+ font-size: 12px;
+ color: #888;
+ margin-bottom: 4px;
+}
+
+.security-status-badge-value {
+ font-size: 18px;
+ font-weight: 600;
+ color: #eee;
+}
+
+.security-status-badge.value-disarmed { color: #888; }
+.security-status-badge.value-learning { color: #ffa726; }
+.security-status-badge.value-armed { color: #66bb6a; }
+.security-status-badge.value-alert { color: #ef5350; }
+
+.security-status-icon {
+ font-size: 32px;
+}
+
+/* Learning Progress Bar */
+.learning-progress-container {
+ margin-top: 16px;
+}
+
+.learning-progress-label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.learning-progress-text {
+ font-size: 12px;
+ color: #888;
+}
+
+.learning-progress-percent {
+ font-size: 13px;
+ font-weight: 600;
+ color: #ffa726;
+}
+
+.learning-progress-bar {
+ height: 8px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.learning-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ffa726, #ff9800);
+ border-radius: 4px;
+ transition: width 0.5s ease;
+ width: 0%;
+}
+
+.learning-ready-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ background: rgba(102, 187, 106, 0.2);
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ color: #66bb6a;
+ margin-top: 8px;
+}
+
+/* Anomaly Stats Grid */
+.security-stats-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.security-stat-card {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px;
+ text-align: center;
+}
+
+.security-stat-value {
+ font-size: 24px;
+ font-weight: 600;
+ color: #4fc3f7;
+}
+
+.security-stat-label {
+ font-size: 11px;
+ color: #888;
+ margin-top: 4px;
+ text-transform: uppercase;
+}
+
+.security-last-event {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.security-last-event-label {
+ font-size: 11px;
+ color: #888;
+ margin-bottom: 4px;
+}
+
+.security-last-event-value {
+ font-size: 13px;
+ color: #ccc;
+}
+
+.security-last-event-time {
+ font-size: 11px;
+ color: #888;
+ margin-top: 4px;
+}
+
+/* Arm/Disarm Button */
+.security-arm-btn {
+ width: 100%;
+ padding: 14px;
+ border-radius: 8px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ border: none;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.security-arm-btn.disarmed {
+ background: rgba(102, 187, 106, 0.2);
+ color: #66bb6a;
+ border: 1px solid rgba(102, 187, 106, 0.3);
+}
+
+.security-arm-btn.disarmed:hover {
+ background: rgba(102, 187, 106, 0.3);
+}
+
+.security-arm-btn.armed {
+ background: rgba(244, 67, 54, 0.2);
+ color: #ef5350;
+ border: 1px solid rgba(244, 67, 54, 0.3);
+}
+
+.security-arm-btn.armed:hover {
+ background: rgba(244, 67, 54, 0.3);
+}
+
+.security-arm-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Anomaly Timeline */
+.anomaly-timeline-controls {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.anomaly-timeline-filter {
+ flex: 1;
+ padding: 8px 12px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ color: #ccc;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.anomaly-timeline-filter:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.anomaly-timeline-filter.active {
+ background: rgba(79, 195, 247, 0.2);
+ border-color: rgba(79, 195, 247, 0.4);
+ color: #4fc3f7;
+}
+
+.anomaly-timeline-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.anomaly-timeline-item {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px;
+ border-left: 3px solid transparent;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.anomaly-timeline-item:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.anomaly-timeline-item.type-unusual_hour { border-left-color: #ffa726; }
+.anomaly-timeline-item.type-unknown_ble { border-left-color: #ab47bc; }
+.anomaly-timeline-item.type-motion_during_away { border-left-color: #ef5350; }
+.anomaly-timeline-item.type-unusual_dwell { border-left-color: #29b6f6; }
+
+.anomaly-item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 6px;
+}
+
+.anomaly-item-type {
+ font-size: 13px;
+ font-weight: 600;
+ color: #eee;
+}
+
+.anomaly-item-time {
+ font-size: 11px;
+ color: #888;
+}
+
+.anomaly-item-details {
+ font-size: 12px;
+ color: #ccc;
+ line-height: 1.4;
+}
+
+.anomaly-item-status {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 600;
+ margin-top: 6px;
+}
+
+.anomaly-item-status.active {
+ background: rgba(239, 83, 80, 0.2);
+ color: #ef5350;
+}
+
+.anomaly-item-status.acknowledged {
+ background: rgba(102, 187, 106, 0.2);
+ color: #66bb6a;
+}
+
+.anomaly-timeline-empty {
+ text-align: center;
+ padding: 32px 20px;
+ color: #666;
+}
+
+.anomaly-timeline-empty-icon {
+ font-size: 32px;
+ margin-bottom: 8px;
+ opacity: 0.5;
+}
+
+.anomaly-timeline-empty-text {
+ font-size: 13px;
+}
+
/* Responsive */
@media (max-width: 600px) {
.panel-sidebar {
@@ -727,4 +1263,575 @@
padding: 8px 12px;
white-space: nowrap;
}
+
+ /* Security panel responsive adjustments */
+ #security-dialog {
+ width: 100%;
+ max-width: 100%;
+ border-radius: 12px 12px 0 0;
+ margin: 0 auto;
+ position: fixed;
+ bottom: 0;
+ transform: translateY(100%);
+ }
+
+ #security-dialog-overlay.visible #security-dialog {
+ transform: translateY(0);
+ }
+
+ #alert-banner {
+ flex-direction: column;
+ align-items: stretch;
+ padding: 12px 16px;
+ }
+
+ .alert-banner-content {
+ margin-bottom: 12px;
+ }
+
+ .alert-banner-actions {
+ justify-content: stretch;
+ }
+
+ .alert-banner-btn {
+ flex: 1;
+ }
+
+ .security-stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .anomaly-timeline-controls {
+ flex-wrap: wrap;
+ }
+
+ .anomaly-timeline-filter {
+ flex: 1 1 calc(50% - 4px);
+ }
+}
+
+/* ============================================
+ Authentication Overlay Styles
+ ============================================ */
+
+#auth-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+
+.auth-modal {
+ background: #1e1e3a;
+ border-radius: 16px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ width: 100%;
+ max-width: 440px;
+ padding: 40px;
+ text-align: center;
+}
+
+.auth-header h1 {
+ margin: 0 0 8px;
+ font-size: 28px;
+ font-weight: 600;
+ color: #eee;
+}
+
+.auth-header p {
+ margin: 0 0 32px;
+ font-size: 15px;
+ color: #aaa;
+}
+
+.auth-body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+}
+
+.auth-instruction {
+ font-size: 14px;
+ color: #ccc;
+ margin: 0 0 16px;
+}
+
+.auth-hint {
+ font-size: 12px;
+ color: #666;
+ margin: 0;
+}
+
+.auth-error {
+ background: rgba(239, 83, 80, 0.15);
+ border: 1px solid rgba(239, 83, 80, 0.3);
+ border-radius: 6px;
+ padding: 10px 16px;
+ font-size: 13px;
+ color: #ef5350;
+ margin: 0;
+}
+
+.pin-inputs {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+}
+
+.pin-digit {
+ width: 44px;
+ height: 56px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 2px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ color: #eee;
+ font-size: 24px;
+ font-weight: 600;
+ text-align: center;
+ transition: all 0.2s;
+}
+
+.pin-digit:focus {
+ outline: none;
+ border-color: #4fc3f7;
+ box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.pin-digit::placeholder {
+ color: #444;
+}
+
+.auth-button {
+ padding: 14px 32px;
+ border-radius: 8px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ border: none;
+ transition: all 0.2s;
+ min-width: 160px;
+}
+
+.auth-button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.auth-button.primary {
+ background: #4fc3f7;
+ color: #1a1a2e;
+}
+
+.auth-button.primary:hover:not(:disabled) {
+ background: #29b6f6;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(79, 195, 247, 0.3);
+}
+
+.auth-button.secondary {
+ background: rgba(255, 255, 255, 0.1);
+ color: #ccc;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.auth-button.secondary:hover {
+ background: rgba(255, 255, 255, 0.15);
+ color: #eee;
+}
+
+/* Responsive adjustments */
+@media (max-width: 480px) {
+ .auth-modal {
+ padding: 32px 24px;
+ }
+
+ .auth-header h1 {
+ font-size: 24px;
+ }
+
+ .pin-digit {
+ width: 38px;
+ height: 48px;
+ font-size: 20px;
+ }
+
+ .auth-button {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+/* ============================================
+ Detection Explainability Panel Styles
+ ============================================ */
+
+/* Explainability Loading */
+.explainability-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+ gap: 12px;
+ color: #888;
+}
+
+.explainability-loading .panel-loading-spinner {
+ margin-right: 0;
+}
+
+/* Confidence Gauge */
+.explainability-confidence {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.confidence-gauge {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ flex-shrink: 0;
+}
+
+.confidence-ring {
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+}
+
+.confidence-ring-bg {
+ fill: none;
+ stroke: rgba(255, 255, 255, 0.1);
+ stroke-width: 3;
+}
+
+.confidence-ring-fill {
+ fill: none;
+ stroke: #66bb6a;
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-dasharray: 0 100;
+ transition: stroke-dasharray 0.5s ease, stroke 0.3s ease;
+}
+
+.confidence-value {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 16px;
+ font-weight: 600;
+ color: #eee;
+}
+
+.confidence-label {
+ font-size: 13px;
+ color: #aaa;
+}
+
+/* Section Styles */
+.explainability-section {
+ margin-bottom: 20px;
+}
+
+.section-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #eee;
+ margin-bottom: 12px;
+}
+
+.section-header {
+ font-size: 14px;
+ font-weight: 600;
+ color: #888;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.toggle-icon {
+ font-size: 12px;
+ color: #888;
+ transition: transform 0.2s ease;
+}
+
+.explainability-section.collapsed .toggle-icon {
+ transform: rotate(-90deg);
+}
+
+.section-content {
+ padding-top: 12px;
+}
+
+/* Links Table */
+.links-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.links-table th {
+ text-align: left;
+ padding: 8px 10px;
+ color: #888;
+ font-weight: 500;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.links-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.links-table tr:last-child td {
+ border-bottom: none;
+}
+
+.links-table-detailed {
+ margin-top: 8px;
+ font-size: 11px;
+}
+
+.link-cell {
+ font-family: monospace;
+}
+
+.link-id {
+ color: #4fc3f7;
+ font-size: 11px;
+}
+
+.deltarms-cell {
+ font-family: monospace;
+ color: #ccc;
+}
+
+.zone-cell {
+ text-align: center;
+}
+
+.zone-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ color: #1a1a2e;
+}
+
+.weight-cell {
+ font-family: monospace;
+ color: #aaa;
+ text-align: right;
+}
+
+.contribution-cell {
+ font-family: monospace;
+ color: #888;
+ text-align: right;
+}
+
+.contributing-cell {
+ text-align: center;
+}
+
+.contributing-badge {
+ display: inline-block;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 600;
+ background: rgba(102, 187, 106, 0.3);
+ color: #66bb6a;
+}
+
+.contributing-badge.contributing-no {
+ background: rgba(136, 136, 136, 0.2);
+ color: #888;
+}
+
+.links-table-detailed tr.contributing-yes {
+ background: rgba(102, 187, 106, 0.08);
+}
+
+.links-table-detailed tr.contributing-no {
+ opacity: 0.7;
+}
+
+/* BLE Match Card */
+.ble-match-card {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.ble-match-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.ble-match-indicator {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.ble-match-name {
+ flex: 1;
+ font-size: 15px;
+ font-weight: 600;
+ color: #eee;
+}
+
+.ble-match-confidence {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: 4px;
+ background: rgba(79, 195, 247, 0.2);
+ color: #4fc3f7;
+}
+
+.ble-match-details {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ble-match-detail {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.ble-match-detail .detail-label {
+ color: #888;
+ min-width: 70px;
+}
+
+.ble-match-detail .detail-value {
+ color: #ccc;
+ font-family: monospace;
+}
+
+/* Footer */
+.explainability-footer {
+ padding-top: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* Blob Context Menu */
+.blob-context-menu {
+ position: fixed;
+ background: rgba(30, 30, 58, 0.98);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ padding: 8px 0;
+ min-width: 200px;
+ z-index: 10000;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ animation: context-menu-fade-in 0.15s ease-out;
+}
+
+@keyframes context-menu-fade-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.blob-context-menu-item {
+ padding: 10px 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+ color: #ccc;
+ transition: background 0.2s, color 0.2s;
+}
+
+.blob-context-menu-item:hover {
+ background: rgba(79, 195, 247, 0.15);
+ color: #4fc3f7;
+}
+
+.blob-context-menu-item:first-child {
+ border-radius: 8px 8px 0 0;
+}
+
+.blob-context-menu-item:last-child {
+ border-radius: 0 0 8px 8px;
+}
+
+.blob-context-menu-divider {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.1);
+ margin: 4px 0;
+}
+
+.blob-context-menu-icon {
+ font-size: 16px;
+ width: 20px;
+ text-align: center;
+}
+
+/* Explainability Sidebar Specific */
+#explainability-sidebar {
+ background: #1e1e3a;
+}
+
+/* Responsive */
+@media (max-width: 600px) {
+ .explainability-confidence {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .confidence-gauge {
+ width: 80px;
+ height: 80px;
+ }
+
+ .confidence-value {
+ font-size: 18px;
+ }
+
+ .links-table {
+ font-size: 11px;
+ }
+
+ .links-table th,
+ .links-table td {
+ padding: 6px 6px;
+ }
}
diff --git a/dashboard/index.html b/dashboard/index.html
index 8faa4b3..946a31a 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -2096,6 +2096,8 @@
+
+
@@ -2132,6 +2134,10 @@
+
+
+
+
diff --git a/dashboard/js/auth.js b/dashboard/js/auth.js
new file mode 100644
index 0000000..b987f93
--- /dev/null
+++ b/dashboard/js/auth.js
@@ -0,0 +1,509 @@
+/**
+ * Spaxel Dashboard - Authentication Module
+ *
+ * Handles PIN setup, login, and session management for the dashboard.
+ * Shows first-run setup page when PIN is not configured.
+ */
+
+(function() {
+ 'use strict';
+
+ // ============================================
+ // Auth State
+ // ============================================
+ const authState = {
+ pinConfigured: null,
+ isAuthenticated: false,
+ isLoading: true,
+ setupStep: 'enter', // 'enter' | 'confirm'
+ enteredPin: '',
+ loginError: '',
+ setupError: ''
+ };
+
+ // ============================================
+ // DOM Elements
+ // ============================================
+ let authOverlay = null;
+ let setupOverlay = null;
+ let loginOverlay = null;
+
+ // ============================================
+ // Auth API
+ // ============================================
+
+ /**
+ * Check if PIN is configured
+ */
+ function checkAuthStatus() {
+ authState.isLoading = true;
+ renderOverlays();
+
+ return fetch('/api/auth/status')
+ .then(function(res) {
+ if (!res.ok) {
+ throw new Error('Failed to check auth status: ' + res.status);
+ }
+ return res.json();
+ })
+ .then(function(data) {
+ authState.pinConfigured = data.pin_configured;
+ authState.isLoading = false;
+
+ // If PIN is configured, check if we have a valid session
+ if (authState.pinConfigured) {
+ return checkSession();
+ } else {
+ // Show first-run setup
+ renderOverlays();
+ }
+ })
+ .catch(function(err) {
+ console.error('[Auth] Error checking auth status:', err);
+ authState.isLoading = false;
+ // On error, assume auth is required
+ authState.pinConfigured = true;
+ renderOverlays();
+ });
+ }
+
+ /**
+ * Check if current session is valid
+ */
+ function checkSession() {
+ return fetch('/api/settings', { method: 'HEAD' })
+ .then(function(res) {
+ if (res.ok) {
+ // Session is valid
+ authState.isAuthenticated = true;
+ renderOverlays();
+ } else {
+ // Session invalid or expired
+ authState.isAuthenticated = false;
+ renderOverlays();
+ }
+ })
+ .catch(function(err) {
+ console.error('[Auth] Error checking session:', err);
+ authState.isAuthenticated = false;
+ renderOverlays();
+ });
+ }
+
+ /**
+ * Setup PIN on first run
+ * @param {string} pin - The PIN to set
+ */
+ function setupPIN(pin) {
+ authState.setupError = '';
+ renderOverlays();
+
+ return fetch('/api/auth/setup', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ pin: pin })
+ })
+ .then(function(res) {
+ if (!res.ok) {
+ return res.text().then(function(text) {
+ throw new Error(text || 'Failed to setup PIN');
+ });
+ }
+ return res.json();
+ })
+ .then(function(data) {
+ // PIN setup successful, reload to start authenticated session
+ window.location.reload();
+ })
+ .catch(function(err) {
+ console.error('[Auth] Error setting up PIN:', err);
+ authState.setupError = err.message || 'Failed to setup PIN';
+ renderOverlays();
+ throw err;
+ });
+ }
+
+ /**
+ * Login with PIN
+ * @param {string} pin - The PIN to authenticate with
+ */
+ function login(pin) {
+ authState.loginError = '';
+ renderOverlays();
+
+ return fetch('/api/auth/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ pin: pin })
+ })
+ .then(function(res) {
+ if (!res.ok) {
+ if (res.status === 401) {
+ throw new Error('Invalid PIN');
+ }
+ return res.text().then(function(text) {
+ throw new Error(text || 'Login failed');
+ });
+ }
+ return res.json();
+ })
+ .then(function(data) {
+ // Login successful, reload to start authenticated session
+ window.location.reload();
+ })
+ .catch(function(err) {
+ console.error('[Auth] Error logging in:', err);
+ authState.loginError = err.message || 'Login failed';
+ renderOverlays();
+ throw err;
+ });
+ }
+
+ /**
+ * Logout and clear session
+ */
+ function logout() {
+ return fetch('/api/auth/logout', {
+ method: 'POST'
+ })
+ .then(function(res) {
+ if (!res.ok) {
+ throw new Error('Logout failed');
+ }
+ return res.json();
+ })
+ .then(function(data) {
+ // Logout successful, reload to show login page
+ window.location.reload();
+ })
+ .catch(function(err) {
+ console.error('[Auth] Error logging out:', err);
+ // Even if error, reload to clear local state
+ window.location.reload();
+ });
+ }
+
+ // ============================================
+ // Overlay Rendering
+ // ============================================
+
+ function renderOverlays() {
+ // Remove existing overlays
+ if (authOverlay) {
+ authOverlay.remove();
+ authOverlay = null;
+ }
+
+ // If loading, show nothing
+ if (authState.isLoading) {
+ return;
+ }
+
+ // If PIN not configured, show first-run setup
+ if (!authState.pinConfigured) {
+ renderSetupOverlay();
+ return;
+ }
+
+ // If PIN configured but not authenticated, show login
+ if (authState.pinConfigured && !authState.isAuthenticated) {
+ renderLoginOverlay();
+ return;
+ }
+ }
+
+ function renderSetupOverlay() {
+ authOverlay = document.createElement('div');
+ authOverlay.id = 'auth-overlay';
+ authOverlay.innerHTML = `
+
+ `;
+
+ document.body.appendChild(authOverlay);
+ setupOverlayEvents();
+ }
+
+ function renderLoginOverlay() {
+ authOverlay = document.createElement('div');
+ authOverlay.id = 'auth-overlay';
+ authOverlay.innerHTML = `
+
+ `;
+
+ document.body.appendChild(authOverlay);
+ loginOverlayEvents();
+ }
+
+ // ============================================
+ // Event Handlers
+ // ============================================
+
+ function setupOverlayEvents() {
+ var inputs = authOverlay.querySelectorAll('.pin-digit');
+ var nextBtn = document.getElementById('setup-next-btn');
+ var confirmBtn = document.getElementById('setup-confirm-btn');
+ var backBtn = document.getElementById('setup-back-btn');
+
+ // Handle input focus and navigation
+ inputs.forEach(function(input, index) {
+ input.addEventListener('input', function(e) {
+ var value = e.target.value;
+
+ // Only allow digits
+ if (!/^\d*$/.test(value)) {
+ e.target.value = '';
+ return;
+ }
+
+ // Move to next input if value entered
+ if (value.length === 1 && index < inputs.length - 1) {
+ inputs[index + 1].focus();
+ }
+
+ // Enable/disable button based on input
+ var pin = getPinFromInputs(inputs);
+ if (authState.setupStep === 'enter') {
+ nextBtn.disabled = pin.length < 4;
+ } else {
+ confirmBtn.disabled = pin.length < 4;
+ }
+ });
+
+ // Handle backspace navigation
+ input.addEventListener('keydown', function(e) {
+ if (e.key === 'Backspace' && !e.target.value && index > 0) {
+ inputs[index - 1].focus();
+ }
+ });
+
+ // Handle paste event
+ input.addEventListener('paste', function(e) {
+ e.preventDefault();
+ var pastedData = (e.clipboardData || window.clipboardData).getData('text');
+ var digits = pastedData.replace(/\D/g, '').slice(0, 8);
+
+ for (var i = 0; i < digits.length && index + i < inputs.length; i++) {
+ inputs[index + i].value = digits[i];
+ }
+
+ // Focus the next empty input or the last one
+ var nextIndex = Math.min(index + digits.length, inputs.length - 1);
+ inputs[nextIndex].focus();
+
+ // Trigger input event on last affected input
+ inputs[nextIndex].dispatchEvent(new Event('input'));
+ });
+ });
+
+ // Next button
+ if (nextBtn) {
+ nextBtn.addEventListener('click', function() {
+ var inputs = document.querySelectorAll('#setup-pin-inputs .pin-digit');
+ var pin = getPinFromInputs(inputs);
+ if (pin.length >= 4) {
+ authState.enteredPin = pin;
+ authState.setupStep = 'confirm';
+ authState.setupError = '';
+ renderSetupOverlay();
+ // Focus first input of confirm step
+ setTimeout(function() {
+ var confirmInputs = document.querySelectorAll('#confirm-pin-inputs .pin-digit');
+ if (confirmInputs.length > 0) {
+ confirmInputs[0].focus();
+ }
+ }, 10);
+ }
+ });
+ }
+
+ // Confirm button
+ if (confirmBtn) {
+ confirmBtn.addEventListener('click', function() {
+ var inputs = document.querySelectorAll('#confirm-pin-inputs .pin-digit');
+ var confirmPin = getPinFromInputs(inputs);
+ if (confirmPin.length >= 4) {
+ if (confirmPin === authState.enteredPin) {
+ // PINS match, proceed with setup
+ setupPIN(authState.enteredPin);
+ } else {
+ // PINS don't match
+ authState.setupError = 'PINs do not match. Please try again.';
+ authState.setupStep = 'enter';
+ authState.enteredPin = '';
+ renderSetupOverlay();
+ }
+ }
+ });
+ }
+
+ // Back button
+ if (backBtn) {
+ backBtn.addEventListener('click', function() {
+ authState.setupStep = 'enter';
+ authState.setupError = '';
+ renderSetupOverlay();
+ });
+ }
+ }
+
+ function loginOverlayEvents() {
+ var inputs = authOverlay.querySelectorAll('.pin-digit');
+ var loginBtn = document.getElementById('login-btn');
+
+ // Handle input focus and navigation
+ inputs.forEach(function(input, index) {
+ input.addEventListener('input', function(e) {
+ var value = e.target.value;
+
+ // Only allow digits
+ if (!/^\d*$/.test(value)) {
+ e.target.value = '';
+ return;
+ }
+
+ // Move to next input if value entered
+ if (value.length === 1 && index < inputs.length - 1) {
+ inputs[index + 1].focus();
+ }
+
+ // Enable/disable button based on input
+ var pin = getPinFromInputs(inputs);
+ loginBtn.disabled = pin.length < 4;
+ });
+
+ // Handle backspace navigation
+ input.addEventListener('keydown', function(e) {
+ if (e.key === 'Backspace' && !e.target.value && index > 0) {
+ inputs[index - 1].focus();
+ }
+ });
+
+ // Handle paste event
+ input.addEventListener('paste', function(e) {
+ e.preventDefault();
+ var pastedData = (e.clipboardData || window.clipboardData).getData('text');
+ var digits = pastedData.replace(/\D/g, '').slice(0, 8);
+
+ for (var i = 0; i < digits.length && index + i < inputs.length; i++) {
+ inputs[index + i].value = digits[i];
+ }
+
+ // Focus the next empty input or the last one
+ var nextIndex = Math.min(index + digits.length, inputs.length - 1);
+ inputs[nextIndex].focus();
+
+ // Trigger input event on last affected input
+ inputs[nextIndex].dispatchEvent(new Event('input'));
+ });
+ });
+
+ // Login button
+ loginBtn.addEventListener('click', function() {
+ var pin = getPinFromInputs(inputs);
+ if (pin.length >= 4) {
+ login(pin);
+ }
+ });
+ }
+
+ function getPinFromInputs(inputs) {
+ var pin = '';
+ for (var i = 0; i < inputs.length; i++) {
+ pin += inputs[i].value;
+ }
+ return pin;
+ }
+
+ // ============================================
+ // Public API
+ // ============================================
+
+ window.SpaxelAuth = {
+ init: function() {
+ checkAuthStatus();
+ },
+
+ logout: function() {
+ return logout();
+ },
+
+ isAuthenticated: function() {
+ return authState.isAuthenticated;
+ },
+
+ refreshStatus: function() {
+ return checkAuthStatus();
+ }
+ };
+
+ // Auto-init on load
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', function() {
+ window.SpaxelAuth.init();
+ });
+ } else {
+ window.SpaxelAuth.init();
+ }
+
+})();
diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go
index 00b8e2b..51798b4 100644
--- a/mothership/cmd/mothership/main.go
+++ b/mothership/cmd/mothership/main.go
@@ -3,6 +3,7 @@ package main
import (
"context"
+ "database/sql"
"encoding/json"
"flag"
"fmt"
@@ -20,10 +21,13 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/hashicorp/mdns"
+ _ "modernc.org/sqlite"
"github.com/spaxel/mothership/internal/api"
+ "github.com/spaxel/mothership/internal/auth"
"github.com/spaxel/mothership/internal/ble"
"github.com/spaxel/mothership/internal/dashboard"
"github.com/spaxel/mothership/internal/diagnostics"
+ "github.com/spaxel/mothership/internal/explainability"
"github.com/spaxel/mothership/internal/fleet"
"github.com/spaxel/mothership/internal/ingestion"
"github.com/spaxel/mothership/internal/ota"
@@ -69,6 +73,56 @@ func main() {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
+ // Create auth handler for PIN-based authentication
+ dataDir := cfg.DataDir
+ if dataDir == "" {
+ dataDir = "/data"
+ }
+ var authHandler *auth.Handler
+ // Open a SQLite connection for auth
+ authDBPath := filepath.Join(dataDir, "spaxel.db")
+ authDB, err := sql.Open("sqlite", authDBPath)
+ if err != nil {
+ log.Printf("[WARN] Failed to open auth database: %v", err)
+ } else {
+ authDB.SetMaxOpenConns(1) // SQLite is single-writer
+ defer authDB.Close()
+
+ // Initialize auth handler
+ authHandler, err = auth.NewHandler(auth.Config{DB: authDB})
+ if err != nil {
+ log.Printf("[WARN] Failed to initialize auth handler: %v", err)
+ authHandler = nil // Disable auth on error
+ } else {
+ defer authHandler.Close()
+ // Register auth routes (public endpoints)
+ authHandler.RegisterRoutes(r)
+ log.Printf("[INFO] Authentication enabled")
+ }
+ }
+
+ // Set up node token validator for ingestion server
+ // Note: authHandler will be nil if auth is disabled, which is fine for development
+ if authHandler != nil {
+ ingestSrv.SetTokenValidator(authHandler.ValidateNodeToken)
+ log.Printf("[INFO] Node token validation enabled")
+ }
+
+ // Helper function to wrap handlers with auth middleware
+ requireAuth := func(next http.HandlerFunc) http.HandlerFunc {
+ if authHandler == nil {
+ return next // No auth if handler not initialized
+ }
+ return authHandler.RequireAuth(next)
+ }
+
+ requireAuthHandler := func(next http.Handler) http.Handler {
+ if authHandler == nil {
+ return next // No auth if handler not initialized
+ }
+ return authHandler.RequireAuthHandler(next)
+ }
+
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@@ -393,7 +447,17 @@ func main() {
// Fleet REST API
fleetHandler := fleet.NewHandler(fleetMgr)
- fleetHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ // Create an authenticated sub-router for fleet API
+ fleetRouter := chi.NewRouter()
+ fleetRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ fleetHandler.RegisterRoutes(fleetRouter)
+ r.Mount("/", fleetRouter)
+ } else {
+ fleetHandler.RegisterRoutes(r)
+ }
// Settings API
settingsHandler, err := api.NewSettingsHandler(filepath.Join(cfg.DataDir, "settings.db"))
@@ -401,7 +465,16 @@ func main() {
log.Printf("[WARN] Failed to create settings handler: %v (settings API disabled)", err)
} else {
defer settingsHandler.Close()
- settingsHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ settingsRouter := chi.NewRouter()
+ settingsRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ settingsHandler.RegisterRoutes(settingsRouter)
+ r.Mount("/", settingsRouter)
+ } else {
+ settingsHandler.RegisterRoutes(r)
+ }
log.Printf("[INFO] Settings API enabled")
}
@@ -411,7 +484,16 @@ func main() {
log.Printf("[WARN] Failed to create zones handler: %v (zones/portals API disabled)", err)
} else {
defer zonesHandler.Close()
- zonesHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ zonesRouter := chi.NewRouter()
+ zonesRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ zonesHandler.RegisterRoutes(zonesRouter)
+ r.Mount("/", zonesRouter)
+ } else {
+ zonesHandler.RegisterRoutes(r)
+ }
log.Printf("[INFO] Zones/Portals API enabled")
}
@@ -421,7 +503,16 @@ func main() {
log.Printf("[WARN] Failed to create triggers handler: %v (triggers API disabled)", err)
} else {
defer triggersHandler.Close()
- triggersHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ triggersRouter := chi.NewRouter()
+ triggersRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ triggersHandler.RegisterRoutes(triggersRouter)
+ r.Mount("/", triggersRouter)
+ } else {
+ triggersHandler.RegisterRoutes(r)
+ }
log.Printf("[INFO] Triggers API enabled")
}
@@ -431,7 +522,16 @@ func main() {
log.Printf("[WARN] Failed to create notifications handler: %v (notifications API disabled)", err)
} else {
defer notificationsHandler.Close()
- notificationsHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ notificationsRouter := chi.NewRouter()
+ notificationsRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ notificationsHandler.RegisterRoutes(notificationsRouter)
+ r.Mount("/", notificationsRouter)
+ } else {
+ notificationsHandler.RegisterRoutes(r)
+ }
log.Printf("[INFO] Notifications API enabled")
}
@@ -441,7 +541,16 @@ func main() {
log.Printf("[WARN] Failed to create events handler: %v (events API disabled)", err)
} else {
defer eventsHandler.Close()
- eventsHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ eventsRouter := chi.NewRouter()
+ eventsRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ eventsHandler.RegisterRoutes(eventsRouter)
+ r.Mount("/", eventsRouter)
+ } else {
+ eventsHandler.RegisterRoutes(r)
+ }
// Wire events handler to dashboard hub for live event broadcasts
eventsHandler.SetHub(dashboardHub)
log.Printf("[INFO] Events API enabled")
@@ -454,7 +563,16 @@ func main() {
log.Printf("[WARN] Failed to create replay handler: %v (replay API disabled)", err)
} else {
defer replayHandler.Close()
- replayHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ replayRouter := chi.NewRouter()
+ replayRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ replayHandler.RegisterRoutes(replayRouter)
+ r.Mount("/", replayRouter)
+ } else {
+ replayHandler.RegisterRoutes(r)
+ }
log.Printf("[INFO] Replay API enabled")
}
}
@@ -466,21 +584,56 @@ func main() {
} else {
defer bleRegistry.Close()
bleHandler := ble.NewHandler(bleRegistry)
- bleHandler.RegisterRoutes(r)
+ if authHandler != nil {
+ bleRouter := chi.NewRouter()
+ bleRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ bleHandler.RegisterRoutes(bleRouter)
+ r.Mount("/", bleRouter)
+ } else {
+ bleHandler.RegisterRoutes(r)
+ }
log.Printf("[INFO] BLE Devices API enabled")
}
+ // Detection explainability API
+ explainabilityHandler := explainability.NewHandler()
+ if authHandler != nil {
+ explainabilityRouter := chi.NewRouter()
+ explainabilityRouter.Use(func(next http.Handler) http.Handler {
+ return authHandler.RequireAuthHandler(next)
+ })
+ explainabilityHandler.RegisterRoutes(explainabilityRouter)
+ r.Mount("/", explainabilityRouter)
+ } else {
+ explainabilityHandler.RegisterRoutes(r)
+ }
+ log.Printf("[INFO] Detection explainability API enabled")
+
// Phase 5: Weather diagnostics REST API
r.Get("/api/weather", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
reports := weatherDiagnostics.GetAllLinkReports()
writeJSON(w, reports)
})
r.Get("/api/weather/{linkID}", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
linkID := chi.URLParam(r, "linkID")
report := weatherDiagnostics.GetReport(linkID)
writeJSON(w, report)
})
r.Get("/api/weather/summary", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
condition, avgConfidence, issueCount := weatherDiagnostics.GetSystemWeatherSummary()
writeJSON(w, map[string]interface{}{
"condition": condition,
@@ -489,6 +642,10 @@ func main() {
})
})
r.Get("/api/weather/{linkID}/weekly", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
linkID := chi.URLParam(r, "linkID")
trend := weatherDiagnostics.GetWeeklyTrend(linkID)
writeJSON(w, trend)
@@ -496,10 +653,18 @@ func main() {
// Phase 5: Coverage and healing status API
r.Get("/api/coverage", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
coverage := fleetHealer.GetCoverage()
writeJSON(w, coverage)
})
r.Get("/api/coverage/history", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
@@ -511,6 +676,10 @@ func main() {
writeJSON(w, history)
})
r.Get("/api/healing/status", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
writeJSON(w, map[string]interface{}{
"degraded": fleetHealer.IsDegraded(),
"online_nodes": fleetHealer.GetOnlineNodes(),
@@ -518,6 +687,10 @@ func main() {
})
})
r.Get("/api/healing/suggest", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
x, z, improvement := fleetHealer.SuggestNodePosition()
worstX, worstZ, worstGDOP := fleetHealer.GetWorstCoverageZone()
writeJSON(w, map[string]interface{}{
@@ -529,6 +702,10 @@ func main() {
// Phase 5: System health API
r.Get("/api/health/system", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
writeJSON(w, map[string]interface{}{
"system_health": pm.GetSystemHealth(),
"link_count": pm.LinkCount(),
@@ -540,10 +717,18 @@ func main() {
// Phase 6: Diurnal learning status API
r.Get("/api/diurnal/status", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
statuses := pm.GetDiurnalLearningStatus()
writeJSON(w, statuses)
})
r.Get("/api/diurnal/status/{linkID}", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
linkID := chi.URLParam(r, "linkID")
allStatuses := pm.GetDiurnalLearningStatus()
for _, status := range allStatuses {
@@ -557,18 +742,30 @@ func main() {
// Link health API - returns all links with health scores and details
r.Get("/api/links", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
links := ingestSrv.GetAllLinksWithHealth()
writeJSON(w, links)
})
// Phase 6: Link diagnostics API
r.Get("/api/links/{linkID}/diagnostics", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
linkID := chi.URLParam(r, "linkID")
diagnoses := diagnosticEngine.GetDiagnoses(linkID)
writeJSON(w, diagnoses)
})
r.Get("/api/links/{linkID}/health-history", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
linkID := chi.URLParam(r, "linkID")
windowStr := r.URL.Query().Get("window")
window := 24 * time.Hour // default 24h
@@ -590,6 +787,10 @@ func main() {
})
r.Get("/api/diagnostics", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
allDiagnoses := diagnosticEngine.GetAllDiagnoses()
writeJSON(w, allDiagnoses)
})
@@ -603,14 +804,34 @@ func main() {
log.Printf("[INFO] OTA firmware server at %s", firmwareDir)
// OTA REST API
- r.Get("/api/firmware", otaSrv.HandleList)
- r.Post("/api/firmware/upload", otaSrv.HandleUpload)
- r.Get("/firmware/{filename}", otaSrv.HandleServe)
+ r.Get("/api/firmware", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ otaSrv.HandleList(w, r)
+ })
+ r.Post("/api/firmware/upload", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ otaSrv.HandleUpload(w, r)
+ })
+ r.Get("/firmware/{filename}", otaSrv.HandleServe) // Public - URL contains SHA256
r.Get("/api/firmware/progress", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(otaMgr.GetProgress())
})
r.Post("/api/firmware/ota-all", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
// Rolling update of all connected nodes
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
@@ -633,7 +854,7 @@ func main() {
provSrv := provisioning.NewServer(cfg.DataDir, cfg.MDNSName, msPort)
r.Post("/api/provision", provSrv.HandleProvision)
- // Firmware manifest for esp-web-tools (onboarding wizard flashing)
+ // Firmware manifest for esp-web-tools (onboarding wizard flashing) - public
r.Get("/api/firmware/manifest", func(w http.ResponseWriter, r *http.Request) {
latest := otaSrv.GetLatest()
manifest := map[string]interface{}{
@@ -663,7 +884,14 @@ func main() {
go dashboardHub.Run()
- r.HandleFunc("/ws/dashboard", dashboardSrv.HandleDashboardWS)
+ // Protect dashboard WebSocket with auth
+ r.HandleFunc("/ws/dashboard", func(w http.ResponseWriter, r *http.Request) {
+ if authHandler != nil && !authHandler.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ dashboardSrv.HandleDashboardWS(w, r)
+ })
// Serve dashboard static files
staticDir := cfg.StaticDir
diff --git a/mothership/internal/auth/handler.go b/mothership/internal/auth/handler.go
new file mode 100644
index 0000000..eb06d23
--- /dev/null
+++ b/mothership/internal/auth/handler.go
@@ -0,0 +1,550 @@
+// Package auth provides PIN-based authentication and session management for the dashboard.
+package auth
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+// Handler handles authentication endpoints.
+type Handler struct {
+ db *sql.DB
+ secretKey []byte // for session token signing
+}
+
+// Config holds handler configuration.
+type Config struct {
+ DB *sql.DB
+ SecretKey []byte
+}
+
+// NewHandler creates a new auth handler.
+func NewHandler(cfg Config) (*Handler, error) {
+ if cfg.DB == nil {
+ return nil, fmt.Errorf("database is required")
+ }
+
+ // Generate random secret key if not provided
+ secretKey := cfg.SecretKey
+ if len(secretKey) == 0 {
+ secretKey = make([]byte, 32)
+ if _, err := rand.Read(secretKey); err != nil {
+ return nil, fmt.Errorf("generate secret key: %w", err)
+ }
+ }
+
+ h := &Handler{
+ db: cfg.DB,
+ secretKey: secretKey,
+ }
+
+ // Initialize auth schema and install secret
+ if err := h.initializeAuth(); err != nil {
+ return nil, fmt.Errorf("initialize auth: %w", err)
+ }
+
+ // Start session cleanup goroutine
+ go h.cleanupExpiredSessions()
+
+ return h, nil
+}
+
+// initializeAuth ensures the auth table has a singleton row and generates an install secret.
+func (h *Handler) initializeAuth() error {
+ // Check if auth table exists and has a row
+ var count int
+ err := h.db.QueryRow("SELECT COUNT(*) FROM auth").Scan(&count)
+ if err != nil {
+ // Table might not exist yet, create it
+ _, err = h.db.Exec(`
+ CREATE TABLE IF NOT EXISTS auth (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ install_secret BLOB NOT NULL,
+ pin_bcrypt TEXT,
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("create auth table: %w", err)
+ }
+ }
+
+ // Create sessions table if it doesn't exist
+ _, err = h.db.Exec(`
+ CREATE TABLE IF NOT EXISTS sessions (
+ session_id TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
+ expires_at INTEGER NOT NULL,
+ last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("create sessions table: %w", err)
+ }
+
+ // Create index on expires_at for efficient cleanup
+ _, err = h.db.Exec(`
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)
+ `)
+ if err != nil {
+ return fmt.Errorf("create sessions index: %w", err)
+ }
+
+ // Check if we have an auth row
+ err = h.db.QueryRow("SELECT COUNT(*) FROM auth WHERE id = 1").Scan(&count)
+ if err != nil {
+ return fmt.Errorf("check auth row: %w", err)
+ }
+
+ if count == 0 {
+ // Generate install secret
+ installSecret := make([]byte, 32)
+ if _, err := rand.Read(installSecret); err != nil {
+ return fmt.Errorf("generate install secret: %w", err)
+ }
+
+ // Insert auth row
+ _, err = h.db.Exec(`
+ INSERT INTO auth (id, install_secret, pin_bcrypt)
+ VALUES (1, ?, NULL)
+ `, installSecret)
+ if err != nil {
+ return fmt.Errorf("insert auth row: %w", err)
+ }
+
+ log.Printf("[INFO] Generated new install secret")
+ }
+
+ return nil
+}
+
+// RegisterRoutes registers auth routes with the given router.
+func (h *Handler) RegisterRoutes(mux interface{ HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) }) {
+ mux.HandleFunc("GET /api/auth/status", h.handleStatus)
+ mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
+ mux.HandleFunc("POST /api/auth/login", h.handleLogin)
+ mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
+}
+
+// handleStatus returns whether a PIN is configured.
+// No authentication required.
+func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
+ var pinBcrypt sql.NullString
+ err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ log.Printf("[ERROR] Failed to check PIN status: %v", err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]bool{
+ "pin_configured": pinBcrypt.Valid,
+ })
+}
+
+// handleSetup sets a PIN on first run.
+// No authentication required, but only works if PIN is not yet set.
+func (h *Handler) handleSetup(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Check if PIN is already configured
+ var pinBcrypt sql.NullString
+ err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
+ if err != nil {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ if pinBcrypt.Valid {
+ http.Error(w, "PIN already configured", http.StatusConflict)
+ return
+ }
+
+ // Parse request
+ var req struct {
+ PIN string `json:"pin"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ // Validate PIN
+ if len(req.PIN) < 4 || len(req.PIN) > 8 {
+ http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest)
+ return
+ }
+
+ // Ensure PIN is numeric
+ for _, c := range req.PIN {
+ if c < '0' || c > '9' {
+ http.Error(w, "PIN must contain only digits", http.StatusBadRequest)
+ return
+ }
+ }
+
+ // Hash PIN with bcrypt (cost 12)
+ hash, err := bcrypt.GenerateFromPassword([]byte(req.PIN), 12)
+ if err != nil {
+ http.Error(w, "Failed to hash PIN", http.StatusInternalServerError)
+ log.Printf("[ERROR] Failed to hash PIN: %v", err)
+ return
+ }
+
+ // Store hash
+ _, err = h.db.Exec(`
+ UPDATE auth
+ SET pin_bcrypt = ?, updated_at = ?
+ WHERE id = 1
+ `, hash, time.Now().UnixMilli())
+ if err != nil {
+ http.Error(w, "Failed to store PIN", http.StatusInternalServerError)
+ log.Printf("[ERROR] Failed to store PIN: %v", err)
+ return
+ }
+
+ log.Printf("[INFO] PIN configured successfully")
+
+ // Create session and set cookie
+ sessionID, err := h.createSession()
+ if err != nil {
+ http.Error(w, "Failed to create session", http.StatusInternalServerError)
+ log.Printf("[ERROR] Failed to create session: %v", err)
+ return
+ }
+
+ h.setSessionCookie(w, sessionID)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
+}
+
+// handleLogin authenticates a user with their PIN.
+// No authentication required.
+func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse request
+ var req struct {
+ PIN string `json:"pin"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ // Get stored PIN hash
+ var pinHash string
+ err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinHash)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "PIN not configured", http.StatusNotFound)
+ } else {
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ }
+ return
+ }
+
+ if pinHash == "" {
+ http.Error(w, "PIN not configured", http.StatusNotFound)
+ return
+ }
+
+ // Verify PIN
+ if err := bcrypt.CompareHashAndPassword([]byte(pinHash), []byte(req.PIN)); err != nil {
+ // Invalid PIN
+ http.Error(w, "Invalid PIN", http.StatusUnauthorized)
+ log.Printf("[WARN] Failed login attempt from %s", r.RemoteAddr)
+ return
+ }
+
+ // Create session
+ sessionID, err := h.createSession()
+ if err != nil {
+ http.Error(w, "Failed to create session", http.StatusInternalServerError)
+ log.Printf("[ERROR] Failed to create session: %v", err)
+ return
+ }
+
+ h.setSessionCookie(w, sessionID)
+
+ log.Printf("[INFO] Successful login from %s", r.RemoteAddr)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
+}
+
+// handleLogout clears the session cookie and deletes the session.
+// Authentication required.
+func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Get session ID from cookie
+ cookie, err := r.Cookie("spaxel_session")
+ if err == nil && cookie.Value != "" {
+ // Delete session from database
+ _, _ = h.db.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value)
+ }
+
+ // Clear cookie by setting max-age to -1
+ http.SetCookie(w, &http.Cookie{
+ Name: "spaxel_session",
+ Value: "",
+ MaxAge: -1,
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+
+ log.Printf("[INFO] Logout from %s", r.RemoteAddr)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
+}
+
+// createSession creates a new session and returns the session ID.
+func (h *Handler) createSession() (string, error) {
+ // Generate 32-byte random session ID (64 hex chars)
+ sessionBytes := make([]byte, 32)
+ if _, err := rand.Read(sessionBytes); err != nil {
+ return "", fmt.Errorf("generate session ID: %w", err)
+ }
+ sessionID := hex.EncodeToString(sessionBytes)
+
+ // Calculate expiry (7 days from now)
+ expiresAt := time.Now().Add(7 * 24 * time.Hour).UnixMilli()
+
+ // Insert session
+ _, err := h.db.Exec(`
+ INSERT INTO sessions (session_id, created_at, expires_at, last_seen_at)
+ VALUES (?, ?, ?, ?)
+ `, sessionID, time.Now().UnixMilli(), expiresAt, time.Now().UnixMilli())
+ if err != nil {
+ return "", fmt.Errorf("insert session: %w", err)
+ }
+
+ return sessionID, nil
+}
+
+// setSessionCookie sets the session cookie on the response.
+func (h *Handler) setSessionCookie(w http.ResponseWriter, sessionID string) {
+ // Detect if we're using HTTPS
+ isSecure := false // In production, check r.TLS != nil or X-Forwarded-Proto
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "spaxel_session",
+ Value: sessionID,
+ MaxAge: 604800, // 7 days in seconds
+ Path: "/",
+ HttpOnly: true,
+ Secure: isSecure,
+ SameSite: http.SameSiteStrictMode,
+ })
+}
+
+// ValidateSession checks if a session is valid and extends it if near expiry.
+// Returns the session ID if valid, empty string otherwise.
+func (h *Handler) ValidateSession(r *http.Request) string {
+ cookie, err := r.Cookie("spaxel_session")
+ if err != nil || cookie.Value == "" {
+ return ""
+ }
+
+ sessionID := cookie.Value
+
+ // Check if session exists and is valid
+ var expiresAt int64
+ err = h.db.QueryRow(`
+ SELECT expires_at FROM sessions WHERE session_id = ?
+ `, sessionID).Scan(&expiresAt)
+ if err != nil {
+ if err != sql.ErrNoRows {
+ log.Printf("[ERROR] Failed to validate session: %v", err)
+ }
+ return ""
+ }
+
+ // Check if expired
+ now := time.Now().UnixMilli()
+ if now > expiresAt {
+ return ""
+ }
+
+ // Rolling session extension: if within 24h of expiry, extend by 7 days
+ if expiresAt-now < 24*60*60*1000 {
+ newExpiresAt := now + 7*24*60*60*1000
+ _, err = h.db.Exec(`
+ UPDATE sessions
+ SET expires_at = ?, last_seen_at = ?
+ WHERE session_id = ?
+ `, newExpiresAt, now, sessionID)
+ if err != nil {
+ log.Printf("[WARN] Failed to extend session: %v", err)
+ }
+ } else {
+ // Just update last_seen_at
+ _, err = h.db.Exec(`
+ UPDATE sessions SET last_seen_at = ? WHERE session_id = ?
+ `, now, sessionID)
+ if err != nil {
+ log.Printf("[WARN] Failed to update last_seen_at: %v", err)
+ }
+ }
+
+ return sessionID
+}
+
+// IsAuthenticated checks if the request is authenticated.
+func (h *Handler) IsAuthenticated(r *http.Request) bool {
+ return h.ValidateSession(r) != ""
+}
+
+// RequireAuth is middleware that requires authentication.
+// Returns 401 if not authenticated.
+func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !h.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next(w, r)
+ }
+}
+
+// RequireAuthHandler wraps a standard http.Handler with authentication.
+func (h *Handler) RequireAuthHandler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !h.IsAuthenticated(r) {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// cleanupExpiredSessions runs periodically to delete expired sessions.
+func (h *Handler) cleanupExpiredSessions() {
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ result, err := h.db.Exec(`
+ DELETE FROM sessions WHERE expires_at < ?
+ `, time.Now().UnixMilli())
+ if err != nil {
+ log.Printf("[ERROR] Failed to cleanup expired sessions: %v", err)
+ continue
+ }
+
+ if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 {
+ log.Printf("[INFO] Cleaned up %d expired sessions", rowsAffected)
+ }
+ }
+}
+
+// Close cleans up resources.
+func (h *Handler) Close() error {
+ // Nothing to clean up currently
+ return nil
+}
+
+// GetInstallSecret retrieves the installation secret.
+func (h *Handler) GetInstallSecret() ([]byte, error) {
+ var secret []byte
+ err := h.db.QueryRow("SELECT install_secret FROM auth WHERE id = 1").Scan(&secret)
+ if err != nil {
+ return nil, fmt.Errorf("get install secret: %w", err)
+ }
+ return secret, nil
+}
+
+// DeriveNodeToken derives a node token from the install secret and node MAC.
+// Uses HMAC-SHA256(install_secret, mac) for secure token derivation.
+func (h *Handler) DeriveNodeToken(mac string) (string, error) {
+ secret, err := h.GetInstallSecret()
+ if err != nil {
+ return "", err
+ }
+
+ // Normalize MAC to uppercase without colons
+ mac = strings.ToUpper(strings.ReplaceAll(mac, ":", ""))
+
+ // Compute HMAC-SHA256(install_secret, mac)
+ h := hmac.New(sha256.New, secret)
+ h.Write([]byte(mac))
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// ValidateNodeToken checks if a node token is valid.
+// Returns true if the token matches the expected HMAC-SHA256(install_secret, mac).
+func (h *Handler) ValidateNodeToken(mac, token string) bool {
+ secret, err := h.GetInstallSecret()
+ if err != nil {
+ log.Printf("[ERROR] Failed to get install secret for token validation: %v", err)
+ return false
+ }
+
+ // Normalize MAC to uppercase without colons
+ mac = strings.ToUpper(strings.ReplaceAll(mac, ":", ""))
+
+ // Compute expected token
+ h := hmac.New(sha256.New, secret)
+ h.Write([]byte(mac))
+ expectedToken := hex.EncodeToString(h.Sum(nil))
+
+ // Use constant-time comparison to prevent timing attacks
+ return subtle.ConstantTimeCompare([]byte(expectedToken), []byte(token)) == 1
+}
+
+// GetInstallSecretForNodes returns the install secret for use by node validation.
+// This is used by the ingestion server to validate node tokens.
+func (h *Handler) GetInstallSecretForNodes() ([]byte, error) {
+ return h.GetInstallSecret()
+}
+
+// Helper function to check if a path should be excluded from auth
+func isPublicPath(path string) bool {
+ publicPaths := []string{
+ "/healthz",
+ "/api/auth/status",
+ "/api/auth/setup",
+ "/api/auth/login",
+ "/api/provision",
+ }
+
+ for _, pp := range publicPaths {
+ if path == pp {
+ return true
+ }
+ }
+
+ // Firmware is served without auth (URL contains SHA256 for integrity)
+ if len(path) > 10 && path[:10] == "/firmware/" {
+ return true
+ }
+
+ return false
+}
diff --git a/mothership/internal/auth/handler_test.go b/mothership/internal/auth/handler_test.go
new file mode 100644
index 0000000..46ab5e0
--- /dev/null
+++ b/mothership/internal/auth/handler_test.go
@@ -0,0 +1,425 @@
+// Package auth provides authentication tests.
+package auth
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+func TestHandler_StatusNotConfigured(t *testing.T) {
+ // Create in-memory database
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ // Create handler
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Test status endpoint
+ req := httptest.NewRequest("GET", "/api/auth/status", nil)
+ w := httptest.NewRecorder()
+ h.handleStatus(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Should return pin_configured: false
+ var resp map[string]bool
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+
+ if resp["pin_configured"] {
+ t.Error("Expected pin_configured to be false initially")
+ }
+}
+
+func TestHandler_SetupPIN(t *testing.T) {
+ // Create in-memory database
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ // Create handler
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Test setup with valid PIN
+ reqBody := `{"pin": "1234"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Check session cookie was set
+ cookies := w.Result().Cookies()
+ var sessionCookie *http.Cookie
+ for _, c := range cookies {
+ if c.Name == "spaxel_session" {
+ sessionCookie = c
+ break
+ }
+ }
+
+ if sessionCookie == nil {
+ t.Error("Expected session cookie to be set")
+ } else if sessionCookie.MaxAge != 604800 {
+ t.Errorf("Expected MaxAge 604800, got %d", sessionCookie.MaxAge)
+ }
+
+ // Verify PIN is now configured
+ var pinBcrypt sql.NullString
+ err = db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !pinBcrypt.Valid {
+ t.Error("Expected PIN to be configured after setup")
+ }
+}
+
+func TestHandler_SetupPINInvalid(t *testing.T) {
+ tests := []struct {
+ name string
+ pin string
+ wantStatus int
+ }{
+ {"too short", "123", http.StatusBadRequest},
+ {"too long", "123456789", http.StatusBadRequest},
+ {"non-numeric", "abcd", http.StatusBadRequest},
+ {"mixed", "12a4", http.StatusBadRequest},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ reqBody := `{"pin": "` + tt.pin + `"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
+ }
+ })
+ }
+}
+
+func TestHandler_SetupPINAlreadyConfigured(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // First setup should succeed
+ reqBody := `{"pin": "1234"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("First setup failed: %d", w.Code)
+ }
+
+ // Second setup should fail
+ req = httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w = httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Errorf("Expected status 409, got %d", w.Code)
+ }
+}
+
+func TestHandler_LoginInvalidPIN(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Setup PIN first
+ reqBody := `{"pin": "1234"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ // Try login with wrong PIN
+ reqBody = `{"pin": "9999"}`
+ req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w = httptest.NewRecorder()
+ h.handleLogin(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("Expected status 401, got %d", w.Code)
+ }
+}
+
+func TestHandler_LoginValidPIN(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Setup PIN first
+ reqBody := `{"pin": "1234"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ // Login with correct PIN
+ reqBody = `{"pin": "1234"}`
+ req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w = httptest.NewRecorder()
+ h.handleLogin(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Check session cookie was set
+ cookies := w.Result().Cookies()
+ var sessionCookie *http.Cookie
+ for _, c := range cookies {
+ if c.Name == "spaxel_session" {
+ sessionCookie = c
+ break
+ }
+ }
+
+ if sessionCookie == nil {
+ t.Error("Expected session cookie to be set")
+ }
+}
+
+func TestHandler_ValidateSession(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Setup and login
+ reqBody := `{"pin": "1234"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ cookies := w.Result().Cookies()
+ var sessionCookie *http.Cookie
+ for _, c := range cookies {
+ if c.Name == "spaxel_session" {
+ sessionCookie = c
+ break
+ }
+ }
+
+ if sessionCookie == nil {
+ t.Fatal("Session cookie not set")
+ }
+
+ // Validate session
+ req = httptest.NewRequest("GET", "/api/test", nil)
+ req.AddCookie(sessionCookie)
+ sessionID := h.ValidateSession(req)
+
+ if sessionID == "" {
+ t.Error("Expected session to be valid")
+ }
+
+ if sessionID != sessionCookie.Value {
+ t.Error("Session ID mismatch")
+ }
+}
+
+func TestHandler_ValidateSessionInvalid(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Test with no cookie
+ req := httptest.NewRequest("GET", "/api/test", nil)
+ sessionID := h.ValidateSession(req)
+
+ if sessionID != "" {
+ t.Error("Expected session to be invalid")
+ }
+
+ // Test with invalid cookie
+ req.AddCookie(&http.Cookie{Name: "spaxel_session", Value: "invalid"})
+ sessionID = h.ValidateSession(req)
+
+ if sessionID != "" {
+ t.Error("Expected session to be invalid")
+ }
+}
+
+func TestHandler_Logout(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ h, err := NewHandler(Config{DB: db})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+
+ // Setup and login
+ reqBody := `{"pin": "1234"}`
+ req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.handleSetup(w, req)
+
+ cookies := w.Result().Cookies()
+ var sessionCookie *http.Cookie
+ for _, c := range cookies {
+ if c.Name == "spaxel_session" {
+ sessionCookie = c
+ break
+ }
+ }
+
+ if sessionCookie == nil {
+ t.Fatal("Session cookie not set")
+ }
+
+ // Logout
+ req = httptest.NewRequest("POST", "/api/auth/logout", nil)
+ req.AddCookie(sessionCookie)
+ w = httptest.NewRecorder()
+ h.handleLogout(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Check cookie was cleared
+ cookies = w.Result().Cookies()
+ var clearedCookie *http.Cookie
+ for _, c := range cookies {
+ if c.Name == "spaxel_session" {
+ clearedCookie = c
+ break
+ }
+ }
+
+ if clearedCookie == nil || clearedCookie.MaxAge != -1 {
+ t.Error("Expected cookie to be cleared (MaxAge=-1)")
+ }
+
+ // Verify session was deleted
+ req = httptest.NewRequest("GET", "/api/test", nil)
+ req.AddCookie(sessionCookie)
+ sessionID := h.ValidateSession(req)
+
+ if sessionID != "" {
+ t.Error("Expected session to be invalid after logout")
+ }
+}
+
+func TestPublicPaths(t *testing.T) {
+ tests := []struct {
+ path string
+ expected bool
+ }{
+ {"/healthz", true},
+ {"/api/auth/status", true},
+ {"/api/auth/setup", true},
+ {"/api/auth/login", true},
+ {"/api/provision", true},
+ {"/firmware/spaxel-1.0.0.bin", true},
+ {"/api/settings", false},
+ {"/api/nodes", false},
+ {"/ws/dashboard", false},
+ {"/ws/node", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ result := isPublicPath(tt.path)
+ if result != tt.expected {
+ t.Errorf("isPublicPath(%q) = %v, want %v", tt.path, result, tt.expected)
+ }
+ })
+ }
+}