Showing 1 changed files with 49 additions and 25 deletions
+49 -25
scripts/host_manager.pl
@@ -110,11 +110,28 @@ sub handle_client {
110 110
         return send_html($client, 200, app_html());
111 111
     }
112 112
     if ($method eq 'GET' && $path eq '/healthz') {
113
-        return send_json($client, 200, { ok => json_bool(1), data => $opt{data} });
113
+        return send_json($client, 200, { ok => json_bool(1) });
114 114
     }
115 115
     if ($method eq 'GET' && $path eq '/api/session') {
116 116
         return send_json($client, 200, { authenticated => is_authenticated(\%headers) ? json_bool(1) : json_bool(0) });
117 117
     }
118
+    if ($method eq 'POST' && $path eq '/api/login') {
119
+        return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
120
+        my $payload = request_payload(\%headers, $body);
121
+        my $otp = $payload->{otp} || '';
122
+        if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
123
+            return send_json($client, 401, { error => 'invalid_otp' });
124
+        }
125
+        my $token = create_session();
126
+        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
127
+    }
128
+    if ($method eq 'POST' && $path eq '/api/logout') {
129
+        expire_session(\%headers);
130
+        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
131
+    }
132
+
133
+    return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
134
+
118 135
     if ($method eq 'GET' && $path eq '/api/hosts') {
119 136
         my $registry = load_registry();
120 137
         return send_json($client, 200, registry_payload($registry));
@@ -130,24 +147,8 @@ sub handle_client {
130 147
         my $registry = load_registry();
131 148
         return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
132 149
     }
133
-    if ($method eq 'POST' && $path eq '/api/login') {
134
-        return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
135
-        my $payload = request_payload(\%headers, $body);
136
-        my $otp = $payload->{otp} || '';
137
-        if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
138
-            return send_json($client, 401, { error => 'invalid_otp' });
139
-        }
140
-        my $token = create_session();
141
-        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
142
-    }
143
-    if ($method eq 'POST' && $path eq '/api/logout') {
144
-        expire_session(\%headers);
145
-        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
146
-    }
147 150
 
148 151
     if ($method eq 'POST' && $path =~ m{^/api/}) {
149
-        return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
150
-
151 152
         if ($path eq '/api/hosts/upsert') {
152 153
             my $payload = request_payload(\%headers, $body);
153 154
             return upsert_host($client, $payload);
@@ -831,7 +832,7 @@ sub app_html {
831 832
     button, .linkbtn { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 6px; padding: 7px 10px; min-height: 34px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
832 833
     button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
833 834
     button.danger { color: var(--bad); }
834
-    button:disabled { opacity: .55; cursor: not-allowed; }
835
+    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
835 836
     input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
836 837
     textarea { min-height: 74px; resize: vertical; }
837 838
     table { width: 100%; border-collapse: collapse; table-layout: fixed; }
@@ -862,7 +863,7 @@ sub app_html {
862 863
   <header>
863 864
     <h1>Host Manager</h1>
864 865
     <form class="auth" id="login-form">
865
-      <span id="auth-state" class="muted">read-only</span>
866
+      <span id="auth-state" class="muted">locked</span>
866 867
       <input id="otp" name="otp" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="OTP">
867 868
       <button class="primary" type="submit">Login</button>
868 869
       <button type="button" id="logout">Logout</button>
@@ -870,11 +871,11 @@ sub app_html {
870 871
   </header>
871 872
   <main>
872 873
     <section class="toolbar">
873
-      <button id="refresh">Refresh</button>
874
-      <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
875
-      <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
876
-      <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
877
-      <button id="write-tsv">Write local-hosts.tsv</button>
874
+      <button id="refresh" data-auth-required>Refresh</button>
875
+      <a class="linkbtn" data-auth-required href="/download/hosts.yaml">hosts.yaml</a>
876
+      <a class="linkbtn" data-auth-required href="/download/local-hosts.tsv">local-hosts.tsv</a>
877
+      <a class="linkbtn" data-auth-required href="/download/monitoring.json">monitoring.json</a>
878
+      <button id="write-tsv" data-auth-required>Write local-hosts.tsv</button>
878 879
       <span id="message" class="muted"></span>
879 880
     </section>
880 881
 
@@ -947,13 +948,36 @@ sub app_html {
947 948
     async function refresh() {
948 949
       const session = await api('/api/session');
949 950
       state.authenticated = session.authenticated;
950
-      $('auth-state').textContent = state.authenticated ? 'authenticated' : 'read-only';
951
+      updateAuthUi();
952
+      if (!state.authenticated) {
953
+        state.hosts = [];
954
+        state.problems = [];
955
+        renderLocked();
956
+        return;
957
+      }
951 958
       const data = await api('/api/hosts');
952 959
       state.hosts = data.hosts || [];
953 960
       state.problems = data.problems || [];
954 961
       render(data);
955 962
     }
956 963
 
964
+    function updateAuthUi() {
965
+      $('auth-state').textContent = state.authenticated ? 'authenticated' : 'locked';
966
+      document.querySelectorAll('[data-auth-required]').forEach(el => {
967
+        if (el.tagName === 'A') {
968
+          el.setAttribute('aria-disabled', state.authenticated ? 'false' : 'true');
969
+        } else {
970
+          el.disabled = !state.authenticated;
971
+        }
972
+      });
973
+    }
974
+
975
+    function renderLocked() {
976
+      $('stats').innerHTML = '<span class="stat">authentication required</span>';
977
+      $('problems').innerHTML = '<div class="muted" style="padding: 8px 0">Login with OTP to access host data, downloads, and configuration actions.</div>';
978
+      $('hosts').innerHTML = '';
979
+    }
980
+
957 981
     function render(data) {
958 982
       $('stats').innerHTML = [
959 983
         ['hosts', data.counts.hosts],