@@ -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: |
@@ -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 |
|
@@ -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 |
|
@@ -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 () => {
|