Showing 4 changed files with 97 additions and 20 deletions
+1 -1
.doc/host-manager.md
@@ -86,7 +86,7 @@ http://192.168.2.102:3000/bogdan/LocalAuthority
86 86
 Instanța runtime de pe jumper este instalată în `/usr/local/xdev-host-manager` și publicată prin:
87 87
 
88 88
 ```text
89
-http://hosts.madagascar.xdev.ro/
89
+https://hosts.madagascar.xdev.ro/
90 90
 ```
91 91
 
92 92
 Secretul TOTP nu este în repo. Pentru bootstrap, citește URI-ul root-only de pe jumper:
+4 -3
deploy/jumper/README.md
@@ -1,6 +1,6 @@
1 1
 # Jumper Deployment
2 2
 
3
-Host Manager rulează pe jumper ca serviciu Perl local, ascultând numai pe `127.0.0.1:8088`. Nginx publică aplicația prin vhost pe IP-ul de management `192.168.2.100:80`.
3
+Host Manager rulează pe jumper ca serviciu Perl local, ascultând numai pe `127.0.0.1:8088`. Nginx publică aplicația prin vhost HTTPS pe IP-ul de management `192.168.2.100:443`; portul `80` redirecționează către HTTPS.
4 4
 
5 5
 Vhost implicit:
6 6
 
@@ -67,13 +67,14 @@ sudo systemctl enable --now host-manager
67 67
 sudo nginx -t
68 68
 sudo systemctl reload nginx
69 69
 curl -fsS http://127.0.0.1:8088/healthz
70
-curl -fsS http://hosts.madagascar.xdev.ro/healthz
70
+curl -k -o /dev/null -w '%{http_code}\n' https://hosts.madagascar.xdev.ro/healthz
71
+# trebuie să întoarcă 404; healthcheck-ul public nu este expus prin nginx
71 72
 ```
72 73
 
73 74
 Verificări de securitate de bază:
74 75
 
75 76
 ```bash
76
-curl -o /dev/null -w '%{http_code}\n' -X POST http://hosts.madagascar.xdev.ro/api/render/local-hosts-tsv
77
+curl -k -o /dev/null -w '%{http_code}\n' -X POST https://hosts.madagascar.xdev.ro/api/render/local-hosts-tsv
77 78
 # trebuie să întoarcă 401 fără sesiune OTP
78 79
 ```
79 80
 
+11 -0
deploy/jumper/nginx-host-manager.conf
@@ -2,6 +2,17 @@ server {
2 2
     listen 192.168.2.100:80;
3 3
     server_name hosts.madagascar.xdev.ro;
4 4
 
5
+    return 301 https://$host$request_uri;
6
+}
7
+
8
+server {
9
+    listen 192.168.2.100:443 ssl;
10
+    server_name hosts.madagascar.xdev.ro;
11
+
12
+    ssl_certificate /etc/pki/tls/certs/jumper.madagascar.xdev.ro.crt;
13
+    ssl_certificate_key /etc/pki/tls/private/jumper.madagascar.xdev.ro.key;
14
+    ssl_protocols TLSv1.2 TLSv1.3;
15
+
5 16
     access_log /var/log/nginx/hosts.madagascar.xdev.ro.access.log main;
6 17
     error_log /var/log/nginx/hosts.madagascar.xdev.ro.error.log warn;
7 18
 
+81 -16
scripts/host_manager.pl
@@ -1450,6 +1450,8 @@ sub app_html {
1450 1450
     }
1451 1451
     .modal-head h2 { margin: 0; font-size: 14px; }
1452 1452
     .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
1453
+    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
1454
+    .form-message.error { color: var(--bad); }
1453 1455
     .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
1454 1456
     @media (max-width: 760px) {
1455 1457
       header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
@@ -1628,8 +1630,9 @@ sub app_html {
1628 1630
           <label>Sources<input name="sources"></label>
1629 1631
           <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
1630 1632
           <label>Notes<input name="notes"></label>
1633
+          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
1631 1634
           <div class="span2 form-actions">
1632
-            <button class="primary" type="submit">Save host</button>
1635
+            <button class="primary" type="submit" id="save-host">Save host</button>
1633 1636
             <button class="danger" type="button" id="delete-host">Delete host</button>
1634 1637
           </div>
1635 1638
         </form>
@@ -1641,6 +1644,7 @@ sub app_html {
1641 1644
 
1642 1645
   <script>
1643 1646
     let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
1647
+    let hostFormSnapshot = '';
1644 1648
 
1645 1649
     const $ = (id) => document.getElementById(id);
1646 1650
     const msg = (text) => { $('message').textContent = text || ''; };
@@ -1905,18 +1909,20 @@ sub app_html {
1905 1909
       const host = state.hosts.find(h => h.id === id);
1906 1910
       if (!host) return;
1907 1911
       const form = $('host-form');
1908
-      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) form.elements[key].value = host[key] || '';
1909
-      form.elements.names.value = (host.names || []).join('\n');
1910
-      form.elements.roles.value = (host.roles || []).join(' ');
1911
-      form.elements.sources.value = (host.sources || []).join(' ');
1912
+      clearHostFormMessage();
1913
+      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
1914
+      hostField('names').value = (host.names || []).join('\n');
1915
+      hostField('roles').value = (host.roles || []).join(' ');
1916
+      hostField('sources').value = (host.sources || []).join(' ');
1912 1917
       openHostModal('Edit host');
1913 1918
     }
1914 1919
 
1915 1920
     function newHost() {
1916 1921
       const form = $('host-form');
1917 1922
       form.reset();
1918
-      form.elements.status.value = 'active';
1919
-      form.elements.monitoring.value = 'pending';
1923
+      clearHostFormMessage();
1924
+      hostField('status').value = 'active';
1925
+      hostField('monitoring').value = 'pending';
1920 1926
       openHostModal('New host');
1921 1927
     }
1922 1928
 
@@ -1924,12 +1930,50 @@ sub app_html {
1924 1930
       $('host-modal-title').textContent = title || 'Edit host';
1925 1931
       $('host-modal').hidden = false;
1926 1932
       document.body.style.overflow = 'hidden';
1927
-      $('host-form').elements.id.focus();
1933
+      hostFormSnapshot = hostFormState();
1934
+      hostField('id').focus();
1935
+    }
1936
+
1937
+    function requestCloseHostModal() {
1938
+      if ($('save-host').disabled) return;
1939
+      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
1940
+      closeHostModal();
1928 1941
     }
1929 1942
 
1930 1943
     function closeHostModal() {
1931 1944
       $('host-modal').hidden = true;
1932 1945
       document.body.style.overflow = '';
1946
+      setHostFormBusy(false);
1947
+      clearHostFormMessage();
1948
+      hostFormSnapshot = '';
1949
+    }
1950
+
1951
+    function hostField(name) {
1952
+      return $('host-form').elements.namedItem(name);
1953
+    }
1954
+
1955
+    function hostFormState() {
1956
+      return JSON.stringify(formObject($('host-form')));
1957
+    }
1958
+
1959
+    function hostFormDirty() {
1960
+      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
1961
+    }
1962
+
1963
+    function setHostFormBusy(busy) {
1964
+      $('save-host').disabled = busy;
1965
+      $('delete-host').disabled = busy;
1966
+      $('close-host-modal').disabled = busy;
1967
+    }
1968
+
1969
+    function setHostFormMessage(text, isError = false) {
1970
+      const message = $('host-form-message');
1971
+      message.textContent = text || '';
1972
+      message.classList.toggle('error', !!isError);
1973
+    }
1974
+
1975
+    function clearHostFormMessage() {
1976
+      setHostFormMessage('');
1933 1977
     }
1934 1978
 
1935 1979
     function formObject(form) {
@@ -2063,34 +2107,55 @@ sub app_html {
2063 2107
     $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
2064 2108
     $('filter').addEventListener('input', renderHosts);
2065 2109
     $('new-host').addEventListener('click', newHost);
2066
-    $('close-host-modal').addEventListener('click', closeHostModal);
2067
-    $('host-modal').addEventListener('click', (event) => {
2068
-      if (event.target === $('host-modal')) closeHostModal();
2069
-    });
2110
+    $('close-host-modal').addEventListener('click', requestCloseHostModal);
2070 2111
     window.addEventListener('keydown', (event) => {
2071
-      if (event.key === 'Escape' && !$('host-modal').hidden) closeHostModal();
2112
+      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
2072 2113
     });
2073 2114
 
2074 2115
     $('host-form').addEventListener('submit', async (event) => {
2075 2116
       event.preventDefault();
2117
+      setHostFormBusy(true);
2118
+      setHostFormMessage('Saving...');
2076 2119
       try {
2077 2120
         await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
2121
+        hostFormSnapshot = hostFormState();
2078 2122
         closeHostModal();
2079 2123
         msg('host saved');
2080 2124
         await refresh();
2081
-      } catch (e) { msg(e.message); }
2125
+      } catch (e) {
2126
+        setHostFormMessage(e.message, true);
2127
+        msg(e.message);
2128
+      } finally {
2129
+        setHostFormBusy(false);
2130
+      }
2131
+    });
2132
+
2133
+    $('host-form').addEventListener('invalid', (event) => {
2134
+      setHostFormMessage('Complete the required host fields before saving.', true);
2135
+    }, true);
2136
+
2137
+    $('host-form').addEventListener('input', () => {
2138
+      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
2082 2139
     });
2083 2140
 
2084 2141
     $('delete-host').addEventListener('click', async () => {
2085
-      const id = $('host-form').elements.id.value;
2142
+      const id = hostField('id').value;
2086 2143
       if (!id || !confirm(`Delete ${id}?`)) return;
2144
+      setHostFormBusy(true);
2145
+      setHostFormMessage('Deleting...');
2087 2146
       try {
2088 2147
         await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
2089 2148
         $('host-form').reset();
2149
+        hostFormSnapshot = hostFormState();
2090 2150
         closeHostModal();
2091 2151
         msg('host deleted');
2092 2152
         await refresh();
2093
-      } catch (e) { msg(e.message); }
2153
+      } catch (e) {
2154
+        setHostFormMessage(e.message, true);
2155
+        msg(e.message);
2156
+      } finally {
2157
+        setHostFormBusy(false);
2158
+      }
2094 2159
     });
2095 2160
 
2096 2161
     $('write-tsv').addEventListener('click', async () => {