Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71057fce6 |
Generated
+8
-8
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2858,7 +2858,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2885,7 +2885,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2902,7 +2902,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2938,7 +2938,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -2948,7 +2948,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -2977,7 +2977,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2991,7 +2991,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.1"
|
version = "0.7.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -2744,6 +2744,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|||||||
.strip_prefix("http://")
|
.strip_prefix("http://")
|
||||||
.unwrap_or(&state.public_base_url),
|
.unwrap_or(&state.public_base_url),
|
||||||
"nic_name": state.nic_name,
|
"nic_name": state.nic_name,
|
||||||
|
"nic_link": state.nic_link,
|
||||||
"subnet_mask": state.subnet_mask,
|
"subnet_mask": state.subnet_mask,
|
||||||
"gateway": state.gateway,
|
"gateway": state.gateway,
|
||||||
"dns_server": state.settings.snapshot().dns_server,
|
"dns_server": state.settings.snapshot().dns_server,
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ pub struct AppState {
|
|||||||
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
||||||
/// interface couldn't be identified.
|
/// interface couldn't be identified.
|
||||||
pub nic_name: String,
|
pub nic_name: String,
|
||||||
|
/// v0.7.2: physical link summary for that NIC (operstate, speed,
|
||||||
|
/// duplex, port MAC) — read from sysfs at startup; empty where
|
||||||
|
/// unavailable. Helps confirm which port answers PXE.
|
||||||
|
pub nic_link: String,
|
||||||
/// Subnet mask of the public interface in dotted-quad form.
|
/// Subnet mask of the public interface in dotted-quad form.
|
||||||
pub subnet_mask: String,
|
pub subnet_mask: String,
|
||||||
/// Default gateway IPv4 address.
|
/// Default gateway IPv4 address.
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: "http://127.0.0.1".into(),
|
public_base_url: "http://127.0.0.1".into(),
|
||||||
nic_name: "lo".into(),
|
nic_name: "lo".into(),
|
||||||
|
nic_link: String::new(),
|
||||||
subnet_mask: "255.0.0.0".into(),
|
subnet_mask: "255.0.0.0".into(),
|
||||||
gateway: "127.0.0.1".into(),
|
gateway: "127.0.0.1".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: public_base_url.clone(),
|
public_base_url: public_base_url.clone(),
|
||||||
nic_name: net.nic_name,
|
nic_name: net.nic_name,
|
||||||
|
nic_link: net.nic_link,
|
||||||
subnet_mask: net.subnet_mask,
|
subnet_mask: net.subnet_mask,
|
||||||
gateway: net.gateway,
|
gateway: net.gateway,
|
||||||
};
|
};
|
||||||
@@ -448,6 +449,12 @@ struct NetworkInfo {
|
|||||||
nic_name: String,
|
nic_name: String,
|
||||||
subnet_mask: String,
|
subnet_mask: String,
|
||||||
gateway: String,
|
gateway: String,
|
||||||
|
/// v0.7.2: physical link summary for the Network tab — operstate,
|
||||||
|
/// negotiated speed/duplex, and the port's own MAC. Helps operators
|
||||||
|
/// in multi-NIC / trunked environments confirm *which* port the PXE
|
||||||
|
/// server actually answers on. Empty when sysfs isn't available
|
||||||
|
/// (non-Linux dev builds) or the NIC wasn't identified.
|
||||||
|
nic_link: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort population of the Network tab's read-only fields. We shell
|
/// Best-effort population of the Network tab's read-only fields. We shell
|
||||||
@@ -508,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info.nic_link = detect_link_info(&info.nic_name);
|
||||||
|
|
||||||
info
|
info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
|
||||||
|
/// is optional — virtual NICs report no speed (`-1` or absent), and
|
||||||
|
/// non-Linux dev machines have no `/sys/class/net` at all — so the
|
||||||
|
/// result is whatever could be read, joined human-readably, or empty.
|
||||||
|
fn detect_link_info(nic: &str) -> String {
|
||||||
|
if nic.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let read = |file: &str| {
|
||||||
|
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let state = read("operstate");
|
||||||
|
if !state.is_empty() {
|
||||||
|
parts.push(format!("link {state}"));
|
||||||
|
}
|
||||||
|
let speed = read("speed");
|
||||||
|
if !speed.is_empty() && speed != "-1" {
|
||||||
|
parts.push(format!("{speed} Mb/s"));
|
||||||
|
}
|
||||||
|
let duplex = read("duplex");
|
||||||
|
if !duplex.is_empty() && duplex != "unknown" {
|
||||||
|
parts.push(format!("{duplex} duplex"));
|
||||||
|
}
|
||||||
|
let mac = read("address");
|
||||||
|
if !mac.is_empty() {
|
||||||
|
parts.push(format!("port {mac}"));
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
fn prefix_to_dotted(prefix: u8) -> String {
|
fn prefix_to_dotted(prefix: u8) -> String {
|
||||||
let prefix = prefix.min(32);
|
let prefix = prefix.min(32);
|
||||||
let mask: u32 = if prefix == 0 {
|
let mask: u32 = if prefix == 0 {
|
||||||
|
|||||||
@@ -912,3 +912,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
||||||
}
|
}
|
||||||
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
||||||
|
|
||||||
|
/* v0.7.2: a label.field directly followed by the card's action button
|
||||||
|
stacked its own 14px bottom margin onto the button's 16px top margin
|
||||||
|
(30px total) — visible on Queue "Launch for all waiting" and the
|
||||||
|
Network "Save". Collapse the doubled gap so every primary action sits
|
||||||
|
the same 16px below its form. */
|
||||||
|
.card .body > label.field:has(+ button) { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* v0.7.2: inline list filter (Available images / Unattended files). */
|
||||||
|
.list-search { padding: 14px 16px 0; }
|
||||||
|
.list-search input { width: 100%; }
|
||||||
|
|||||||
+174
-130
@@ -199,109 +199,47 @@
|
|||||||
})[k] || (k || 'Unknown');
|
})[k] || (k || 'Unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.7.0: the Boot rules card — ordered first-match-wins rules
|
// v0.7.2: compact read-out of saved group rules — created from the
|
||||||
// (MAC prefix / architecture → target) plus the optional
|
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
|
||||||
// boot-decision webhook. Saved as one config because rule order
|
// there saves a rule instead of a pin). First match wins, top to
|
||||||
// matters. With no rules and no webhook, behavior is identical to
|
// bottom. The boot-decision webhook remains available via the API
|
||||||
// before the feature existed.
|
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
|
||||||
function bootRulesCard(cfg, targetOptions) {
|
function groupRulesCard(cfg, targetOptions) {
|
||||||
const archChoices = [
|
const rules = (cfg && cfg.rules) || [];
|
||||||
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
if (!rules.length) return null;
|
||||||
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
const titleFor = id => {
|
||||||
];
|
const t = targetOptions.find(x => x.id === id);
|
||||||
// v0.7.1: optional first-boot binary pin. "Auto" lets the
|
return t ? t.title : id;
|
||||||
// escalation ladder learn per machine; pinning skips the learning
|
|
||||||
// walk entirely (e.g. a rack known to run Secure Boot → shim).
|
|
||||||
const modeChoices = [
|
|
||||||
['', 'auto (learn)'], ['firmware', 'Firmware NIC'],
|
|
||||||
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
|
||||||
];
|
|
||||||
const rules = (cfg.rules || []).map(r => Object.assign({}, r));
|
|
||||||
const tbody = el('tbody', {});
|
|
||||||
const msg = el('div', {class:'msg'});
|
|
||||||
const webhookInput = el('input', {type:'text', spellcheck:'false',
|
|
||||||
placeholder:'http://automation.example/boot-decision (optional)',
|
|
||||||
value: cfg.webhook_url || ''});
|
|
||||||
|
|
||||||
const targetSelect = (val) => el('select', {},
|
|
||||||
[el('option', {value:''}, '— target —')]
|
|
||||||
.concat(targetOptions.map(t =>
|
|
||||||
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
|
|
||||||
|
|
||||||
const redraw = () => {
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
if (!rules.length) {
|
|
||||||
tbody.appendChild(el('tr', {}, el('td', {colspan:'7', class:'empty', style:'padding:14px'},
|
|
||||||
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target — or to pin a boot binary (e.g. Secure Boot racks → shim, zero failed cycles).')));
|
|
||||||
}
|
|
||||||
rules.forEach((r, i) => {
|
|
||||||
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
|
|
||||||
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
|
|
||||||
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
|
|
||||||
archChoices.map(([v, label]) =>
|
|
||||||
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
|
|
||||||
const tgtSel = targetSelect(r.target || '');
|
|
||||||
tgtSel.onchange = e => { r.target = e.target.value; };
|
|
||||||
const modeSel = el('select', {onchange: e => { r.driver_mode = e.target.value; }},
|
|
||||||
modeChoices.map(([v, label]) =>
|
|
||||||
el('option', Object.assign({value: v}, v === (r.driver_mode || '') ? {selected:''} : {}), label)));
|
|
||||||
const noteIn = el('input', {type:'text', placeholder:'note',
|
|
||||||
value: r.note || '', oninput: e => { r.note = e.target.value; }});
|
|
||||||
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
|
|
||||||
enabled.checked = r.enabled !== false;
|
|
||||||
tbody.appendChild(el('tr', {}, [
|
|
||||||
el('td', {}, macIn),
|
|
||||||
el('td', {}, archSel),
|
|
||||||
el('td', {}, tgtSel),
|
|
||||||
el('td', {}, modeSel),
|
|
||||||
el('td', {}, noteIn),
|
|
||||||
el('td', {style:'text-align:center'}, enabled),
|
|
||||||
el('td', {style:'text-align:right'},
|
|
||||||
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
redraw();
|
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
|
||||||
|
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
|
||||||
const addBtn = el('button', {class:'ghost', onclick: () => {
|
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
|
||||||
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''});
|
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
|
||||||
redraw();
|
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
|
||||||
}}, '+ Add rule');
|
el('td', {}, r.driver_mode
|
||||||
const saveBtn = el('button', {onclick: async () => {
|
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
|
||||||
// A rule needs at least one effect: a target or a boot-binary pin.
|
: el('span', {class:'tag'}, 'auto')),
|
||||||
const bad = rules.find(r => r.enabled !== false && !r.target && !r.driver_mode);
|
el('td', {}, r.note || ''),
|
||||||
if (bad) { msg.textContent = 'Every enabled rule needs a target or a boot-binary pin.'; msg.className = 'msg err'; return; }
|
el('td', {style:'text-align:right'},
|
||||||
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()});
|
el('button', {class:'danger', onclick: async () => {
|
||||||
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
|
if (!confirm('Remove this group rule?')) return;
|
||||||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
}}, 'Save rules');
|
(fresh.rules = fresh.rules || []).splice(i, 1);
|
||||||
|
await putJSON('/api/boot-rules', fresh);
|
||||||
|
render('hosts');
|
||||||
|
}}, 'Remove')),
|
||||||
|
]));
|
||||||
return el('div', {class:'card'}, [
|
return el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Boot rules'),
|
el('h2', {}, 'Group rules'),
|
||||||
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('table', {}, [
|
||||||
el('table', {}, [
|
el('thead', {}, el('tr', {}, [
|
||||||
el('thead', {}, el('tr', {}, [
|
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
||||||
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
|
||||||
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},'On'), el('th',{},''),
|
])),
|
||||||
])),
|
el('tbody', {}, rows),
|
||||||
tbody,
|
|
||||||
]),
|
|
||||||
el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]),
|
|
||||||
el('label', {class:'field', style:'margin-top:16px;display:block'}, [
|
|
||||||
el('span', {class:'name'}, 'Boot-decision webhook (optional)'),
|
|
||||||
webhookInput,
|
|
||||||
el('span', {class:'hint'},
|
|
||||||
'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' +
|
|
||||||
'A 200 reply of {"target": "<entry-id>"} chains to that target; anything ' +
|
|
||||||
'else (404, timeout, error) falls through to the menu — a dead endpoint ' +
|
|
||||||
'can never block PXE.'),
|
|
||||||
]),
|
|
||||||
msg,
|
|
||||||
el('p', {class:'msg', style:'margin-top:10px'},
|
|
||||||
'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -501,6 +439,11 @@
|
|||||||
el('div', {class:'v'}, net.server_ip || '?'),
|
el('div', {class:'v'}, net.server_ip || '?'),
|
||||||
el('div', {class:'k'}, 'NIC name'),
|
el('div', {class:'k'}, 'NIC name'),
|
||||||
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
||||||
|
// v0.7.2: physical link details (operstate · speed · duplex ·
|
||||||
|
// port MAC) so the operator can confirm WHICH port answers
|
||||||
|
// PXE in multi-NIC / trunked environments.
|
||||||
|
el('div', {class:'k'}, 'Link'),
|
||||||
|
el('div', {class:'v'}, net.nic_link || '—'),
|
||||||
el('div', {class:'k'}, 'Subnet mask'),
|
el('div', {class:'k'}, 'Subnet mask'),
|
||||||
el('div', {class:'v'}, net.subnet_mask || '?'),
|
el('div', {class:'v'}, net.subnet_mask || '?'),
|
||||||
el('div', {class:'k'}, 'Gateway'),
|
el('div', {class:'k'}, 'Gateway'),
|
||||||
@@ -896,6 +839,12 @@
|
|||||||
render('storage');
|
render('storage');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.7.2: searchable haystack for the list filter — filename,
|
||||||
|
// detected family, category, and source all match.
|
||||||
|
const searchText = [
|
||||||
|
i.filename, familyLabel(i.introspection.family), i.category || '',
|
||||||
|
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
|
||||||
|
].join(' ').toLowerCase();
|
||||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||||
el('td', {}, [
|
el('td', {}, [
|
||||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||||
@@ -940,8 +889,24 @@
|
|||||||
}}, 'Remove'),
|
}}, 'Remove'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
tr.dataset.search = searchText;
|
||||||
rowsAndEditors.push(tr, editorRow);
|
rowsAndEditors.push(tr, editorRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.7.2: client-side filter over the image table. Rows travel in
|
||||||
|
// (row, password-editor) pairs; filtering hides both, and an open
|
||||||
|
// editor stays closed for filtered-out rows.
|
||||||
|
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)',
|
||||||
|
spellcheck:'false', oninput: () => {
|
||||||
|
const q = isoSearch.value.trim().toLowerCase();
|
||||||
|
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
|
||||||
|
const row = rowsAndEditors[k];
|
||||||
|
const editor = rowsAndEditors[k + 1];
|
||||||
|
const show = !q || (row.dataset.search || '').includes(q);
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (!show) editor.style.display = 'none';
|
||||||
|
}
|
||||||
|
}});
|
||||||
const isoTable = isos.length
|
const isoTable = isos.length
|
||||||
? el('table', {}, [
|
? el('table', {}, [
|
||||||
el('thead', {}, el('tr', {}, [
|
el('thead', {}, el('tr', {}, [
|
||||||
@@ -1279,7 +1244,10 @@
|
|||||||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||||||
|
|
||||||
const unattRows = unattendedFiles.length
|
const unattRows = unattendedFiles.length
|
||||||
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
|
? unattendedFiles.map(f => el('div', {
|
||||||
|
class:'nfs-row',
|
||||||
|
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
|
||||||
|
}, [
|
||||||
el('span', {class:'dot ok'}),
|
el('span', {class:'dot ok'}),
|
||||||
el('div', {}, [
|
el('div', {}, [
|
||||||
el('div', {class:'id'}, [
|
el('div', {class:'id'}, [
|
||||||
@@ -1307,6 +1275,17 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
unattDrop, unattFile, unattMsg,
|
unattDrop, unattFile, unattMsg,
|
||||||
|
// v0.7.2: filter for big answer-file libraries.
|
||||||
|
unattendedFiles.length > 1 ? (() => {
|
||||||
|
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)',
|
||||||
|
spellcheck:'false', style:'margin-top:14px', oninput: () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
unattRows.forEach(r => {
|
||||||
|
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}});
|
||||||
|
return search;
|
||||||
|
})() : null,
|
||||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'These answer files drive unattended installs. Attach one to a ' +
|
'These answer files drive unattended installs. Attach one to a ' +
|
||||||
@@ -1353,6 +1332,7 @@
|
|||||||
el('h2', {}, 'Available images'),
|
el('h2', {}, 'Available images'),
|
||||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||||
]),
|
]),
|
||||||
|
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
|
||||||
isoTable,
|
isoTable,
|
||||||
]),
|
]),
|
||||||
]), unattendedAdvanced]);
|
]), unattendedAdvanced]);
|
||||||
@@ -1378,8 +1358,22 @@
|
|||||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
|
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'});
|
||||||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
||||||
|
// v0.7.2: the former separate "Boot rules" card folded into this
|
||||||
|
// form. A full MAC with no architecture saves a per-host pin
|
||||||
|
// exactly as before; a MAC *prefix* and/or an architecture saves a
|
||||||
|
// first-match-wins group rule instead. Same form, one mental model.
|
||||||
|
const archSel = el('select', {}, [
|
||||||
|
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||||||
|
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
|
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
|
||||||
|
// escalation ladder learn; shim = known Secure Boot fleet).
|
||||||
|
const binSel = el('select', {}, [
|
||||||
|
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
|
||||||
|
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
const targetSel = el('select', {},
|
const targetSel = el('select', {},
|
||||||
[el('option', {value:''}, '— choose a target —')]
|
[el('option', {value:''}, '— choose a target —')]
|
||||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||||||
@@ -1392,21 +1386,40 @@
|
|||||||
// hostname/IP templated into the served answer file.
|
// hostname/IP templated into the served answer file.
|
||||||
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
||||||
|
|
||||||
|
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
|
||||||
const upsertBtn = el('button', {onclick: async () => {
|
const upsertBtn = el('button', {onclick: async () => {
|
||||||
if (!macInput.value || !targetSel.value) {
|
const mac = macInput.value.trim();
|
||||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
|
||||||
|
if (!isGroup) {
|
||||||
|
// Exact-MAC pin — unchanged behavior.
|
||||||
|
if (!mac || !targetSel.value) {
|
||||||
|
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const r = await postJSON('/api/hosts', Object.assign({
|
||||||
|
mac, target: targetSel.value, label: labelInput.value,
|
||||||
|
}, profileFields.read()));
|
||||||
|
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const r = await postJSON('/api/hosts', Object.assign({
|
// Group rule (prefix and/or architecture). Per-host profile
|
||||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
// fields don't apply to a group — they're per-machine values.
|
||||||
}, profileFields.read()));
|
if (!targetSel.value && !binSel.value) {
|
||||||
if (r.ok) {
|
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
|
||||||
render('hosts');
|
|
||||||
} else {
|
|
||||||
const t = await r.text();
|
|
||||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
|
||||||
}
|
}
|
||||||
}}, 'Bind MAC to target');
|
const p = profileFields.read();
|
||||||
|
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
|
||||||
|
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
|
(cfg.rules = cfg.rules || []).push({
|
||||||
|
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
|
||||||
|
driver_mode: binSel.value, enabled: true, note: labelInput.value,
|
||||||
|
});
|
||||||
|
const r = await putJSON('/api/boot-rules', cfg);
|
||||||
|
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
}}, 'Bind to target');
|
||||||
|
|
||||||
const rows = hosts.map(h => {
|
const rows = hosts.map(h => {
|
||||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||||
@@ -1463,23 +1476,38 @@
|
|||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
el('div', {class:'form-row'}, [
|
el('div', {class:'form-row cols-3'}, [
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'MAC address or prefix'),
|
||||||
|
macInput,
|
||||||
|
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'),
|
||||||
|
]),
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Architecture (optional)'),
|
||||||
|
archSel,
|
||||||
|
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
|
||||||
|
]),
|
||||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||||
el('span', {class:'name'}, 'Target'),
|
el('span', {class:'name'}, 'Target'),
|
||||||
targetSel,
|
targetSel,
|
||||||
el('span', {class:'hint'},
|
el('span', {class:'hint'},
|
||||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
||||||
]),
|
]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Boot binary (optional)'),
|
||||||
|
binSel,
|
||||||
|
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
||||||
upsertBtn, msg,
|
upsertBtn, msg,
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
|
||||||
'short-circuits past the interactive menu and chains directly. ' +
|
'past the interactive menu and chains directly. Decision order: ' +
|
||||||
'If an unattended file is selected, the matching kernel argument ' +
|
'exact MAC pin → first matching group rule → menu. ' +
|
||||||
'is injected and the hostname/IP are templated into the answer file.'),
|
'If an unattended file is selected on a pin, the matching kernel ' +
|
||||||
|
'argument is injected and the hostname/IP are templated into the answer file.'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
@@ -1489,7 +1517,7 @@
|
|||||||
]),
|
]),
|
||||||
table,
|
table,
|
||||||
]),
|
]),
|
||||||
bootRulesCard(rulesCfg, reserved.concat(targets)),
|
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Host log'),
|
el('h2', {}, 'Host log'),
|
||||||
@@ -2166,9 +2194,24 @@
|
|||||||
el('div', {class:'about-hero'}, [
|
el('div', {class:'about-hero'}, [
|
||||||
el('h2', {}, 'OpenPXE'),
|
el('h2', {}, 'OpenPXE'),
|
||||||
el('p', {class:'lead'},
|
el('p', {class:'lead'},
|
||||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||||||
'the image once, run it forever.'),
|
'boot it, image from it, and install unattended. One container, one ' +
|
||||||
|
'static binary, nothing installed on clients, nothing leaving your network.'),
|
||||||
|
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
|
||||||
|
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
|
||||||
|
'ISOs become menu entries automatically, served on demand from local ' +
|
||||||
|
'disk or your existing NFS, SMB, or SFTP libraries.'],
|
||||||
|
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
|
||||||
|
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
|
||||||
|
'negotiated automatically and remembered — no toggles, no client prep.'],
|
||||||
|
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
|
||||||
|
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
|
||||||
|
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
|
||||||
|
].map(([h, body]) => el('div', {}, [
|
||||||
|
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
|
||||||
|
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
|
||||||
|
]))),
|
||||||
el('div', {class:'who'}, [
|
el('div', {class:'who'}, [
|
||||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||||
@@ -2179,15 +2222,16 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||||
el('p', {class:'msg', style:'margin-top:18px'},
|
el('p', {class:'msg', style:'margin-top:18px'},
|
||||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||||||
'executes is generated from the settings on these tabs — there is no ' +
|
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||||||
'hand-written .ipxe path anywhere in this product.'),
|
'without internet, and locked-down OpenShift clusters run the same ' +
|
||||||
|
'image, the same way, indefinitely.'),
|
||||||
el('p', {class:'msg'},
|
el('p', {class:'msg'},
|
||||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
'Principled by default: OpenPXE never asks an operator to install ' +
|
||||||
'awkward environments — air-gapped labs, customer sites without ' +
|
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||||||
'an operator to install drivers signed with test certificates or to ' +
|
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||||||
'flip "testsigning" on a target machine.'),
|
'maintain and no internals to learn.'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user