Compare commits

...

4 Commits

Author SHA1 Message Date
nearxos
123fd8748e Update provisioning documentation and scripts for improved Proxmox deployment</message>
<message>Add a new step-by-step guide for deploying the CM4 eMMC provisioning service on a new Proxmox instance, enhancing clarity for users. Update existing documentation to reflect changes in network configuration options, including the introduction of LAN subnet settings for DHCP and TFTP. Modify cloud-init scripts to ensure proper management of DNS settings and improve the handling of network interfaces. Additionally, enhance the toggle script for network boot to dynamically read the LAN gateway from configuration files, streamlining the setup process and improving user experience.
2026-03-03 08:24:18 +02:00
nearxos
7dd9f2b74f Update .gitignore to prevent tracking of the GNSS bootstrap image file and replace the existing binary with a new version, ensuring compatibility and improved performance in the provisioning process. 2026-02-24 09:16:56 +02:00
nearxos
2baf6423a5 Update GNSS bootstrap image to the latest version, ensuring compatibility and improved performance. This change replaces the previous image file with an updated binary, enhancing the overall provisioning process. 2026-02-24 08:52:51 +02:00
nearxos
1501c17ab9 Enhance cloud-init scripts and dashboard for improved USB boot functionality</message>
<message>Update the bootstrap script to ensure hostname resolution by adding entries to /etc/hosts, preventing "sudo: unable to resolve host" errors. Modify user-data.bootstrap to include the same hostname resolution logic. Revise dashboard templates to reflect the new project name "GNSS Guard Provisioning" and improve user interface elements related to USB boot operations, including clearer instructions and status messages. These changes enhance the overall user experience and streamline the provisioning process.
2026-02-24 08:50:32 +02:00
24 changed files with 608 additions and 69 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
gnss-bootstrap-20260223-215010.img.xz
gnss-bootstrap-20260223-215010.img.xz.bak

View File

@@ -13,9 +13,10 @@ Revisions are tracked project-wide; see repo root **README.md** and `scripts/bum
emmc-provisioning/
├── README.md ← You are here
├── docs/ Documentation
│ ├── DEPLOY-NEW-PROXMOX.md Step-by-step: deploy to a new Proxmox instance
│ ├── EMMC-PROVISIONING-GUIDE.md Full setup and usage
│ ├── NETWORK-BOOT-LXC.md Network boot (PXE/dnsmasq) and LXC
│ ├── PROXMOX-LXC-DEPLOYMENT.md Proxmox LXC + host setup
│ ├── PROXMOX-LXC-DEPLOYMENT.md Proxmox LXC + host setup (reference)
│ └── PORTAL_STYLING_GUIDE.md Dashboard UI styling reference
├── host/ Scripts that run on the provisioning host (Proxmox host)
│ ├── flash-emmc-on-connect.sh rpiboot + wait for Backup/Deploy choice, then dd
@@ -53,6 +54,7 @@ emmc-provisioning/
## Quick start
1. **Read** [docs/EMMC-PROVISIONING-GUIDE.md](docs/EMMC-PROVISIONING-GUIDE.md) for setup and usage.
2. **Proxmox:** Use [scripts/deploy-to-proxmox.sh](scripts/deploy-to-proxmox.sh) to deploy to a Proxmox host; see [docs/PROXMOX-LXC-DEPLOYMENT.md](docs/PROXMOX-LXC-DEPLOYMENT.md).
3. **Manual host:** Copy scripts from `host/` to the host and install the udev rule (see the guide).
4. Put **golden.img** in `/var/lib/cm4-provisioning/` (or your configured path). When a device is detected (USB or network), the **dashboard** asks **Backup** or **Deploy**.
2. **Deploy to a new Proxmox:** Follow [docs/DEPLOY-NEW-PROXMOX.md](docs/DEPLOY-NEW-PROXMOX.md) for clear step-by-step instructions.
3. **Proxmox reference:** [scripts/deploy-to-proxmox.sh](scripts/deploy-to-proxmox.sh) and [docs/PROXMOX-LXC-DEPLOYMENT.md](docs/PROXMOX-LXC-DEPLOYMENT.md) for options, layout, and troubleshooting.
4. **Manual host:** Copy scripts from `host/` to the host and install the udev rule (see the guide).
5. Put **golden.img** in `/var/lib/cm4-provisioning/` (or your configured path). When a device is detected (USB or network), the **dashboard** asks **Backup** or **Deploy**.

View File

@@ -1,4 +1,9 @@
#!/bin/bash
# Minimal bootstrap script for cloud-init first boot (test).
set -e
# Ensure hostname resolves (avoids "sudo: unable to resolve host")
H="$(hostname)"
grep -q "127.0.1.1.*$H" /etc/hosts || echo "127.0.1.1 $H" >> /etc/hosts
echo "[$(date -Iseconds)] test completed" | tee -a /var/log/cloud-init-bootstrap.log

View File

@@ -9,6 +9,12 @@
package_update: true
package_upgrade: false
# Keep /etc/hosts in sync with hostname (from meta-data or set below)
manage_etc_hosts: true
# DNS is managed by systemd-resolved; we do not overwrite /etc/resolv.conf
manage_resolv_conf: false
packages:
- curl
@@ -19,7 +25,76 @@ write_files:
PasswordAuthentication yes
PermitRootLogin no
# Push current DHCP DNS into systemd-resolved (for dhcpcd/dhclient when NM doesn't feed resolved).
# With no args: discover DNS from lease or resolvectl and push to resolved for default IF.
# NetworkManager feeds resolved automatically; this covers first boot and non-NM setups.
- path: /usr/local/bin/update-resolv-from-dhcp.sh
content: |
#!/bin/sh
# Push DHCP DNS to systemd-resolved so resolv.conf (stub) uses it.
IF="${IFACE:-$(ip -o -4 route show to default 2>/dev/null | awk '{print $5}' | head -1)}"
[ -z "$IF" ] && exit 0
DNS=""
if [ -s /run/systemd/resolve/resolv.conf ]; then
DNS=$(grep -E '^nameserver\s+' /run/systemd/resolve/resolv.conf | awk '{print $2}' | tr '\n' ' ')
fi
if [ -z "$DNS" ]; then
DNS=$(resolvectl dns "$IF" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | tr '\n' ' ')
fi
if [ -z "$DNS" ]; then
LEASE=$(ls /var/lib/dhcp/dhclient.*.leases 2>/dev/null | head -1)
[ -n "$LEASE" ] && DNS=$(grep -oP 'option domain-name-servers \K[^;]+' "$LEASE" 2>/dev/null | tr ',' '\n' | tr -d ' ' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | tr '\n' ' ')
fi
[ -n "$DNS" ] && resolvectl dns "$IF" $DNS
permissions: '0755'
# dhclient: feed systemd-resolved on every lease acquire/renew (DHCP provides new_domain_name_servers)
- path: /etc/dhcp/dhclient-exit-hooks.d/zzz-update-resolv-conf
content: |
#!/bin/sh
# Run by dhclient on exit; push DHCP DNS into systemd-resolved.
[ -z "$new_domain_name_servers" ] && exit 0
[ -z "$interface" ] && exit 0
resolvectl dns "$interface" $new_domain_name_servers
permissions: '0755'
# NetworkManager: resolved is fed by NM by default; this only runs our script as fallback (e.g. if resolved started late).
- path: /etc/NetworkManager/dispatcher.d/99-update-resolv-from-dhcp
content: |
#!/bin/sh
[ "$2" = "up" ] || [ "$2" = "dhcp4-change" ] || exit 0
export IFACE="$1"
/usr/local/bin/update-resolv-from-dhcp.sh
permissions: '0755'
# Tell NetworkManager to send DHCP DNS to systemd-resolved (so every DHCP update is applied).
- path: /etc/NetworkManager/conf.d/99-use-resolved.conf
content: |
[main]
dns=systemd-resolved
rc-manager=unmanaged
# Fallback: push DHCP DNS to resolved once when network is up (e.g. dhcpcd-only or first boot).
- path: /etc/systemd/system/update-resolv-from-dhcp.service
content: |
[Unit]
Description=Push DHCP DNS to systemd-resolved
After=network-online.target systemd-resolved.service
WantedBy=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/update-resolv-from-dhcp.sh
RemainAfterExit=yes
runcmd:
# Use systemd-resolved for DNS; /etc/resolv.conf -> stub so all lookups go through resolved (DHCP DNS applied by NM/hooks).
- systemctl enable systemd-resolved.service
- systemctl start systemd-resolved.service
- rm -f /etc/resolv.conf && ln -s /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
# Push current DHCP DNS into resolved once at first boot (in case NM hasn't applied yet).
- /usr/local/bin/update-resolv-from-dhcp.sh
- systemctl enable update-resolv-from-dhcp.service
- systemctl enable ssh
- systemctl start ssh
# Download and run bootstrap script (edit URL to match your file server)

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · CM4 Provisioning</title>
<title>Admin · GNSS Guard Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -76,6 +76,23 @@
<p id="goldenInfo" class="mono" style="font-size:0.9rem;">Loading…</p>
</div>
<!-- Update boot (EEPROM) -->
<div class="section" id="adminUpdateBootSection">
<h2 class="section-title">Update boot (EEPROM)</h2>
<p id="adminUpdateBootHint" class="mono" style="font-size:0.85rem; color:var(--text-muted);">When a device is connected in USB boot mode, it appears here. Use this to write EEPROM boot order (e.g. eMMC only) to the device.</p>
<div id="adminUpdateBootDevice" style="display:none; margin-top:0.5rem;">
<p class="mono" style="font-size:0.9rem; margin-bottom:0.5rem;">Device in USB boot mode</p>
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
<label>Boot order:</label>
<select id="adminEepromPreset" class="eeprom-preset" title="Boot order">
<option value="0x1">eMMC only</option>
</select>
<button type="button" class="btn btn-outline btn-sm" id="adminUpdateEepromBtn">Update EEPROM</button>
</div>
</div>
<p id="adminUpdateBootNone" class="mono" style="font-size:0.85rem; color:var(--text-muted); display:none;">No device in USB boot mode. Connect a device with eMMC disable jumper and USB to host.</p>
</div>
<!-- Backups -->
<div class="section">
<h2 class="section-title">
@@ -247,6 +264,47 @@
authFetch('/api/admin/users', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username: username, password: password}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { document.getElementById('addUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; fetchUsers(); } else alert(d.error); });
};
function fetchPendingDevices() {
authFetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){
var devEl = document.getElementById('adminUpdateBootDevice');
var noneEl = document.getElementById('adminUpdateBootNone');
if (d.usb) {
if (devEl) devEl.style.display = 'block';
if (noneEl) noneEl.style.display = 'none';
} else {
if (devEl) devEl.style.display = 'none';
if (noneEl) noneEl.style.display = 'block';
}
}).catch(function(){});
}
function fetchEepromPresets() {
authFetch('/api/eeprom-presets').then(function(r){ return r.json(); }).then(function(d){
var sel = document.getElementById('adminEepromPreset');
if (!sel) return;
sel.innerHTML = '';
(d.presets || []).forEach(function(p){
var opt = document.createElement('option');
opt.value = p.id || p.value;
opt.textContent = p.label || p.id || p.value;
sel.appendChild(opt);
});
}).catch(function(){});
}
fetchPendingDevices();
fetchEepromPresets();
var adminUpdateEepromBtn = document.getElementById('adminUpdateEepromBtn');
if (adminUpdateEepromBtn) {
adminUpdateEepromBtn.onclick = function(){
var presetEl = document.getElementById('adminEepromPreset');
var bootOrder = (presetEl && presetEl.value) ? presetEl.value : '0x1';
authFetch('/api/device-action', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ source: 'usb', action: 'eeprom_update', boot_order: bootOrder }) })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.ok) { fetchPendingDevices(); alert('Update EEPROM sent. The host will write to the device.'); } else alert(d.error || 'Failed'); })
.catch(function(){ alert('Request failed'); });
};
}
setInterval(fetchPendingDevices, 3000);
fetchBackups(); fetchCloudinit(); fetchUsers(); fetchLogs(); fetchGolden();
setInterval(fetchLogs, 30000);
</script>

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud-init build · Admin · CM4 Provisioning</title>
<title>Cloud-init build · Admin · GNSS Guard Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deploy · CM4 Provisioning</title>
<title>Deploy · GNSS Guard Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -96,7 +96,7 @@
<body>
<div class="wrap">
<header class="header">
<h1>CM4 eMMC Provisioning</h1>
<h1>GNSS Guard Provisioning</h1>
<a href="/admin">Admin</a>
</header>
@@ -107,7 +107,7 @@
<h2 class="card-title">Status</h2>
<div id="status" class="status-row">
<span id="statusPill" class="status-pill idle">Idle</span>
<span id="statusMsg" class="status-msg">Waiting for device</span>
<span id="statusMsg" class="status-msg">Waiting for Device in USB boot mode.</span>
<button type="button" id="statusClearBtn" class="btn btn-outline btn-sm" style="margin-left: auto;">Clear status</button>
</div>
<div id="statusErr" class="status-err" style="display:none;"></div>
@@ -128,7 +128,7 @@
</div>
<div class="card">
<h2 class="card-title">Capture or deploy</h2>
<h2 class="card-title">Deploy and Backup</h2>
<p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode.</p>
@@ -142,12 +142,12 @@
<p id="goldenInfo" class="golden-info">Loading…</p>
</div>
<details class="card" style="padding: 0;">
<details class="card" style="padding: 0;" open>
<summary>Recent log</summary>
<div class="inner"><pre id="log" class="log-pre"></pre></div>
</details>
<details class="card" style="padding: 0; margin-top: 1rem;">
<details class="card" style="padding: 0; margin-top: 1rem;" open>
<summary>DHCP leases</summary>
<div class="inner">
<p id="leasesNote" class="help-sub" style="margin-top:0;">Provisioning LAN (dnsmasq) — when dashboard runs on LXC.</p>
@@ -162,7 +162,7 @@
<div class="inner">
<p class="help-sub">USB boot</p>
<ol class="steps-list">
<li><span class="num">1</span> Set reTerminal to <strong>boot mode</strong> (eMMC disable jumper).</li>
<li><span class="num">1</span> Set device to <strong>boot mode</strong> (eMMC disable jumper).</li>
<li><span class="num">2</span> Connect USB to host; choose <strong>Backup</strong> or <strong>Deploy</strong> above.</li>
<li><span class="num">3</span> Remove jumper and power cycle when done.</li>
</ol>
@@ -178,7 +178,7 @@
const phase = data.phase || 'idle';
document.getElementById('statusPill').className = 'status-pill ' + phase;
document.getElementById('statusPill').textContent = phaseLabels[phase] || phase;
document.getElementById('statusMsg').textContent = data.message || '';
document.getElementById('statusMsg').textContent = data.message || (phase === 'idle' ? 'Waiting for Device in USB boot mode.' : '');
const err = document.getElementById('statusErr');
if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; }
if (phase === 'done') scheduleDoneClear();
@@ -204,7 +204,7 @@
shrinkWrap.style.display = 'block';
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">USB device — choose Backup, Deploy, or Update EEPROM</div><div class="device-actions-row"><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button> <select class="eeprom-preset" title="Boot order"><option value="0x1">eMMC only</option></select> <button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>';
el.innerHTML = '<div class="device-desc">USB device — choose Backup or Deploy</div><div class="device-actions-row"><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
} else shrinkWrap.style.display = 'none';
noPending.style.display = hasAny ? 'none' : 'block';
@@ -212,10 +212,6 @@
btn.onclick = function() {
const body = { source: btn.getAttribute('data-source'), action: btn.getAttribute('data-action') };
if (body.source === 'network') body.mac = btn.getAttribute('data-mac');
if (body.action === 'eeprom_update' && body.source === 'usb') {
const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset');
body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0x1';
}
if (body.action === 'backup' && document.getElementById('shrinkAfterBackup').checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
@@ -267,7 +263,14 @@
fetch('/api/first-boot-status').then(function(r){ return r.json(); }).then(renderFirstBootStatus).catch(function(){});
}
function fetchPending() { fetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){ renderPending(d.usb || null, d.network || []); }).catch(function(){ renderPending(null, []); }); }
function fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); }
function fetchLog() {
fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){
var logEl = document.getElementById('log');
if (!logEl) return;
logEl.textContent = d.log || '';
logEl.scrollTop = logEl.scrollHeight;
}).catch(function(){});
}
function fetchGolden() {
fetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
const el = document.getElementById('goldenInfo');

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CM4 eMMC Provisioning</title>
<title>GNSS Guard Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -359,8 +359,8 @@
<body>
<div class="wrap">
<header class="header">
<h1>CM4 eMMC Provisioning</h1>
<p>Deploy or backup reTerminal via USB boot mode</p>
<h1>GNSS Guard Provisioning</h1>
<p>Deploy or backup device via USB boot mode</p>
</header>
<!-- 1. Current status -->
@@ -368,12 +368,12 @@
<h2 class="section-title">Current status</h2>
<div id="status" class="status-row">
<span id="statusPill" class="status-pill idle">Idle</span>
<span id="statusMsg" class="status-msg">Waiting for device</span>
<span id="statusMsg" class="status-msg">Waiting for Device in USB boot mode.</span>
<button type="button" id="statusClearBtn" class="btn btn-outline btn-sm" style="margin-left: auto;">Clear status</button>
</div>
<div id="statusErr" class="status-err" style="display:none;"></div>
<div id="statusGoldenHint" class="backup-deploy-hint" style="display:none; margin-top:0.75rem;">
No golden image is required to <strong>capture</strong>. Connect a device in USB boot mode; when it appears under “Capture image or deploy”, click <strong>Backup</strong> to save its image. Then set that backup as golden in the list below.
No golden image is required to <strong>backup</strong>. Connect a device in USB boot mode; when it appears under “Deploy and Backup”, click <strong>Backup</strong> to save its image. Then set that backup as golden in the list below.
<button type="button" id="statusClearHintBtn" class="btn btn-outline btn-sm" style="margin-left:0.5rem;">Clear message</button>
</div>
<div id="statusMeta" class="status-meta" style="display:none;"></div>
@@ -384,8 +384,8 @@
<!-- 2. Capture (Backup) or Deploy -->
<section class="section">
<h2 class="section-title">Capture image or deploy</h2>
<p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p>
<h2 class="section-title">Deploy and Backup</h2>
<p class="backup-deploy-hint">Connect a device in USB boot mode. When it appears below, click <strong>Backup</strong> to save its eMMC to a file, or <strong>Deploy</strong> to write the golden image to the device (requires a golden image set in Admin).</p>
<p id="shrinkOptionWrap" class="backup-deploy-hint" style="display:none; margin-top:0.5rem;">
<label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label> <span class="backups-mono" style="font-size:0.8rem;">(reduces size; requires PiShrink on host)</span>
</p>
@@ -398,7 +398,7 @@
</span>
<span>— USB boot mode</span>
</div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Connect reTerminal in USB boot mode (eMMC disable jumper + USB to host) — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Connect device in USB boot mode (eMMC disable jumper + USB to host) — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p>
</section>
<!-- 3. Saved backups -->
@@ -477,7 +477,7 @@
<div class="inner">
<p class="help-sub">USB boot mode</p>
<ol class="steps-list">
<li><span class="num">1</span> Set reTerminal to <strong>boot mode</strong> (eMMC disable jumper, e.g. J2 / nRPIBOOT).</li>
<li><span class="num">1</span> Set device to <strong>boot mode</strong> (eMMC disable jumper, e.g. J2 / nRPIBOOT).</li>
<li><span class="num">2</span> Connect <strong>USB slave</strong> to the host and power on. The device appears above; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
<li><span class="num">3</span> When done, remove the jumper and power cycle to boot from eMMC.</li>
</ol>
@@ -485,7 +485,7 @@
</details>
<!-- 5. Recent log (collapsible) -->
<details class="section" style="padding:0;">
<details class="section" style="padding:0;" open>
<summary>Recent log</summary>
<div class="inner">
<pre id="log" class="log-pre"></pre>
@@ -516,7 +516,7 @@
const phase = data.phase || 'idle';
statusPill.className = 'status-pill ' + phase;
statusPill.textContent = phaseLabels[phase] || phase;
statusMsg.textContent = data.message || '';
statusMsg.textContent = data.message || (phase === 'idle' ? 'Waiting for Device in USB boot mode.' : '');
if (data.error) {
statusErr.textContent = data.error;
@@ -566,7 +566,7 @@
if (shrinkWrap) shrinkWrap.style.display = 'block';
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup, Deploy, or Update EEPROM</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button><select class="eeprom-preset" title="Boot order"><option value="0x1">eMMC only</option></select><button type="button" class="btn btn-outline" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup or Deploy</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
} else {
if (shrinkWrap) shrinkWrap.style.display = 'none';
@@ -583,10 +583,6 @@
const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action };
if (mac) body.mac = mac;
if (action === 'eeprom_update' && source === 'usb') {
const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset');
body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0x1';
}
const shrinkCb = document.getElementById('shrinkAfterBackup');
if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
@@ -785,7 +781,12 @@
fetch('/api/pending-devices').then(function(r) { return r.json(); }).then(function(d) { renderPending(d.usb || null, d.network || []); }).catch(function() { renderPending(null, []); });
}
function fetchLog() {
fetch('/api/log').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('log').textContent = d.log || ''; }).catch(function() {});
fetch('/api/log').then(function(r) { return r.json(); }).then(function(d) {
var logEl = document.getElementById('log');
if (!logEl) return;
logEl.textContent = d.log || '';
logEl.scrollTop = logEl.scrollHeight;
}).catch(function() {});
}
function fetchBackups() {
fetch('/api/backups').then(function(r) { return r.json(); }).then(function(d) {

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin login · CM4 Provisioning</title>
<title>Admin login · GNSS Guard Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -28,7 +28,7 @@
<body>
<div class="box">
<h1>Admin login</h1>
<p class="sub">CM4 eMMC provisioning dashboard</p>
<p class="sub">GNSS Guard Provisioning admin</p>
{% if error %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="{{ url_for('login') }}">
<label for="username">Username</label>

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portal files · Admin · CM4 Provisioning</title>
<title>Portal files · Admin · GNSS Guard Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -0,0 +1,152 @@
# Deploy CM4 eMMC Provisioning to a New Proxmox Instance
Step-by-step guide to deploy the provisioning service (host + LXC) on a **new** Proxmox server. For redeploy/update and troubleshooting, see [PROXMOX-LXC-DEPLOYMENT.md](PROXMOX-LXC-DEPLOYMENT.md).
---
## Prerequisites (before running the deploy script)
| Requirement | Details |
|-------------|---------|
| **Proxmox host** | A Proxmox VE node (new or existing) where you want the service. |
| **SSH as root** | You must be able to run `ssh root@YOUR_PROXMOX_HOST` with **key-based auth** (no password prompt). |
| **Proxmox storage** | At least one active storage (e.g. `local` or `local-lvm`). Check on the host: `pvesm status`. |
| **Host internet** (recommended) | Needed so the deploy script can download the Debian 12 LXC template (if missing), and install **usbboot** and **PiShrink** on the host. Without internet, deploy still runs but you must install usbboot and PiShrink manually later. |
**Optional (set before deploy):**
- `DEPLOY_ROOTFS_STORAGE=local-lvm` — Skip interactive storage choice when creating the LXC.
- `DEPLOY_LXC_ROOT_PASSWORD=yourpassword` — Set LXC root password and enable SSH.
- `DEPLOY_LXC_SSH_KEY=/path/to/pub` — Copy this key into the LXC (default: `~/.ssh/id_ed25519.pub` or `id_rsa.pub`).
- `CM4_BACKUPS_HOST_PATH=/mnt/storage/cm4-backups` — Store backups on this host path (create the directory on the host if needed).
- **Network (WAN/LAN):**
`DEPLOY_LXC_WAN_BRIDGE=vmbr0` (default), `DEPLOY_LXC_WAN_IP=dhcp` (default),
`DEPLOY_LXC_LAN_BRIDGE=vmbr1`, `DEPLOY_LXC_LAN_SUBNET=10.20.50.1/24` — To add eth1 as provisioning LAN with a custom subnet.
---
## Step 1: Run the deploy script
From your **workstation** (where the repo is cloned), run:
```bash
cd /path/to/reTerminal\ DM4
./emmc-provisioning/scripts/deploy-to-proxmox.sh root@YOUR_PROXMOX_HOST
```
Replace `YOUR_PROXMOX_HOST` with the Proxmox hostname or IP (e.g. `10.20.30.40`).
**Example with options:**
```bash
DEPLOY_ROOTFS_STORAGE=local-lvm \
DEPLOY_LXC_ROOT_PASSWORD='YourSecurePassword' \
DEPLOY_LXC_LAN_BRIDGE=vmbr1 \
DEPLOY_LXC_LAN_SUBNET=10.20.50.1/24 \
./emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.20.30.40
```
- On **first run**, the script will ask you to choose LXC rootfs storage (unless `DEPLOY_ROOTFS_STORAGE` is set). It then creates the LXC, installs host scripts, udev, systemd units, and the dashboard in the LXC.
- The script prints the **LXC IP** at the end. Note it for the next steps (or get it with:
`ssh root@YOUR_PROXMOX_HOST "pct exec \$(pct list -no-header -output vmid,name | awk '\''\$2==\"cm4-provisioning\"{print \$1}'\'') -- hostname -I"`).
---
## Step 2: Install usbboot on the host (if host had no internet during deploy)
USB flash/backup needs **rpiboot** on the Proxmox **host**. If the deploy log said usbboot install failed or was skipped:
**From your workstation:**
```bash
scp emmc-provisioning/scripts/install-usbboot-on-host.sh root@YOUR_PROXMOX_HOST:/tmp/
ssh root@YOUR_PROXMOX_HOST "bash /tmp/install-usbboot-on-host.sh"
```
**Or on the Proxmox host** (if `/tmp/emmc-provisioning-deploy` is still there):
```bash
ssh root@YOUR_PROXMOX_HOST
bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
```
---
## Step 3: Add a golden image (required for Deploy)
To **write** an image to a device (Deploy), the host must have a **golden image** at `/var/lib/cm4-provisioning/golden.img`. Backup (read from device) works without it.
**Option A — From the dashboard**
1. Open **http://&lt;LXC-IP&gt;:5000** (use the LXC IP from the deploy output).
2. Build a cloud-init image or upload/set an existing backup as golden (see dashboard Admin).
**Option B — Copy an image from your machine**
```bash
scp /path/to/your-golden.img root@YOUR_PROXMOX_HOST:/var/lib/cm4-provisioning/golden.img
```
---
## Step 4: (Optional) SSH into the LXC
If you set `DEPLOY_LXC_ROOT_PASSWORD` or had a default SSH key, you can already run:
```bash
ssh root@<LXC-IP>
```
Otherwise, enable root SSH and add your key:
```bash
./emmc-provisioning/scripts/setup-lxc-ssh.sh root@YOUR_PROXMOX_HOST
# Or with password: ROOT_PASSWORD='YourPassword' ./emmc-provisioning/scripts/setup-lxc-ssh.sh root@YOUR_PROXMOX_HOST ~/.ssh/id_ed25519.pub
```
---
## Step 5: (Optional) Network boot (DHCP + TFTP on eth1)
Only if you deployed with **`DEPLOY_LXC_LAN_BRIDGE`** (and optionally `DEPLOY_LXC_LAN_SUBNET`) and want to offer network boot to devices on that LAN:
```bash
./emmc-provisioning/scripts/setup-network-boot-on-lxc.sh root@<LXC-IP>
```
See [NETWORK-BOOT-LXC.md](NETWORK-BOOT-LXC.md) for details.
---
## Step 6: (Optional) Install PiShrink on the host
If the deploy log said PiShrink install failed (e.g. no internet), and you want **Shrink/Compress** in the dashboard to work:
```bash
ssh root@YOUR_PROXMOX_HOST "bash /tmp/emmc-provisioning-deploy/scripts/install-pishrink-on-host.sh"
```
Or from your machine (stream the script): use the same pattern as in [PROXMOX-LXC-DEPLOYMENT.md](PROXMOX-LXC-DEPLOYMENT.md) for `install-pishrink-on-host.sh`.
---
## Summary checklist
| Step | Action | Required? |
|------|--------|------------|
| 1 | Run `deploy-to-proxmox.sh root@YOUR_PROXMOX_HOST` | **Yes** |
| 2 | Install usbboot on host (if deploy couldnt) | For USB flash/backup |
| 3 | Add `golden.img` for Deploy | For Deploy only |
| 4 | SSH to LXC (or use setup-lxc-ssh.sh) | Optional |
| 5 | Run setup-network-boot-on-lxc.sh (if using eth1 LAN) | Optional |
| 6 | Install PiShrink on host (if deploy couldnt) | For Shrink/Compress |
**After deployment:**
- **Dashboard:** http://&lt;LXC-IP&gt;:5000
- **Golden image path (host and LXC):** `/var/lib/cm4-provisioning/golden.img`
- **Disable auto-flash:** `ssh root@YOUR_PROXMOX_HOST "rm /etc/cm4-provisioning/enabled"`
- **Enable again:** `ssh root@YOUR_PROXMOX_HOST "touch /etc/cm4-provisioning/enabled"`
Full reference: [PROXMOX-LXC-DEPLOYMENT.md](PROXMOX-LXC-DEPLOYMENT.md).

View File

@@ -1,4 +1,4 @@
# How to edit cloud-init files on the device before capturing the image
# How to edit cloud-init files on the device or on an image file
The cloud-init **NoCloud** files live on the **boot partition**. On the running device they are at:
@@ -77,6 +77,34 @@ They are owned by **root** and need **sudo** to edit.
---
## Method 3: Edit on an image file (.img or .img.xz)
To edit cloud-init **inside a disk image** (e.g. `gnss-bootstrap-20260223-215010.img.xz`) without booting the device:
1. **Run the helper script** (requires `sudo` for loop device and mount):
```bash
cd /path/to/reTerminal-DM4
./emmc-provisioning/scripts/edit-cloudinit-on-image.sh gnss-bootstrap-20260223-215010.img.xz
```
2. The script will:
- Decompress the `.img.xz` (if needed; allow ~3GB free space and a minute or two)
- Attach the image with `losetup` and mount the **boot** (FAT32) partition
- Open your `$EDITOR` (or `nano`) on `user-data`, `meta-data`, and `network-config`
- After you save and exit, unmount and recompress, **overwriting** the original `.img.xz`
3. **Back up the image** before running if you want to keep the original:
```bash
cp gnss-bootstrap-20260223-215010.img.xz gnss-bootstrap-20260223-215010.img.xz.bak
```
4. **Options:**
- `--no-recompress` — Leave the image decompressed after editing (do not overwrite the `.img.xz`).
- `--replace-with-repo` — Copy `user-data`, `meta-data`, and `network-config` from `emmc-provisioning/cloud-init/` into the image before opening the editor (useful to start from repo templates).
---
## What to edit (typical)
- **meta-data**

View File

@@ -48,7 +48,13 @@ The script will:
## Proxmox: adding eth1 to the LXC
If you create the container by hand or want a second interface:
Use the deploy script with **`DEPLOY_LXC_LAN_BRIDGE`** and **`DEPLOY_LXC_LAN_SUBNET`** so the LXC is created with eth1 (LAN) from the start:
```bash
DEPLOY_LXC_LAN_BRIDGE=vmbr1 DEPLOY_LXC_LAN_SUBNET=10.20.50.1/24 ./emmc-provisioning/scripts/deploy-to-proxmox.sh root@YOUR_PROXMOX_HOST
```
Or add a second interface to an existing container by hand:
1. On the **Proxmox host**, add a second network device to the container, e.g.:
```bash
@@ -65,6 +71,22 @@ If you create the container by hand or want a second interface:
Your current LXC already has eth0 (10.130.60.141) and eth1 (10.20.50.1); the setup script only adds DHCP, TFTP, and NAT.
### Changing the LAN subnet
When you deploy with **`DEPLOY_LXC_LAN_SUBNET`** (e.g. `10.100.1.1/24`), the deploy script writes **`/opt/cm4-provisioning/lan-subnet.conf`** inside the LXC with `LAN_GW`, `LAN_CIDR`, and the DHCP range. All LXC services use this file:
- **dnsmasq** (DHCP range and, via the toggle script, TFTP next-server)
- **nftables/iptables** (NAT source subnet)
- **toggle-network-boot-dhcp.sh** (option 66/67 next-server)
So changing `DEPLOY_LXC_LAN_SUBNET` and **re-running the deploy script** updates `lan-subnet.conf`. To apply the new subnet to dnsmasq and NAT, **re-run the setup script** after redeploying:
```bash
./emmc-provisioning/scripts/setup-network-boot-on-lxc.sh root@<LXC-IP>
```
Then run **toggle enable** again if you use network boot: `ssh root@<LXC-IP> /opt/cm4-provisioning/toggle-network-boot-dhcp.sh enable`
## After setup: reTerminal network boot
1. Set the reTerminal **boot order** to try eMMC first, then network (e.g. `BOOT_ORDER=0xf21`): use the dashboard **Update EEPROM** when the device is connected via USB boot, or set manually (usbboot recovery / `rpi-eeprom-config` on device). Not set by first-boot.

View File

@@ -2,6 +2,8 @@
The auto-flash **runs on the Proxmox host** (where the USB device appears). The **LXC** holds the dashboard and shares the **golden image** directory with the host.
**New deployment:** For a clear step-by-step guide to deploy on a **new** Proxmox instance, see **[DEPLOY-NEW-PROXMOX.md](DEPLOY-NEW-PROXMOX.md)**.
## One-command deploy
From your repo, a single run deploys **all** host and LXC files (scripts, systemd units, udev, dashboard):
@@ -12,6 +14,8 @@ From your repo, a single run deploys **all** host and LXC files (scripts, system
Optional env: `CM4_BACKUPS_HOST_PATH=/path`, `DEPLOY_ROOTFS_STORAGE=local-lvm`, `DEPLOY_LXC_ROOT_PASSWORD=secret` (set root password in LXC and enable SSH), `DEPLOY_LXC_SSH_KEY=/path/to/pub` (default: `~/.ssh/id_ed25519.pub` or `id_rsa.pub` — copied to LXC root so you can `ssh root@<LXC-IP>`).
**Network (WAN / LAN):** `DEPLOY_LXC_WAN_BRIDGE=vmbr0` (default), `DEPLOY_LXC_WAN_IP=dhcp` (default or e.g. `192.168.1.10/24`), `DEPLOY_LXC_LAN_BRIDGE=vmbr1` (if set, add eth1 as LAN), `DEPLOY_LXC_LAN_SUBNET=10.20.50.1/24` (LXC IP on LAN; default `10.20.50.1/24` when LAN bridge is set). Example: `DEPLOY_LXC_WAN_BRIDGE=vmbr0 DEPLOY_LXC_LAN_BRIDGE=vmbr1 DEPLOY_LXC_LAN_SUBNET=10.20.50.1/24 ./scripts/deploy-to-proxmox.sh root@host`.
The script **finds the container by hostname `cm4-provisioning`** (any existing ID). If none exists, it **creates a new LXC with the next available ID**. So you can redeploy repeatedly without assuming a fixed ID like 201.
## What is deployed
@@ -210,7 +214,7 @@ Or copy `scripts/monitor-from-host.sh` to the host and run `./monitor-from-host.
4. **"No 'bootcode' files found in mass-storage-gadget64"** Usually because `bootfiles.bin` is a **broken symlink** (e.g. `-> ../firmware/bootfiles.bin`) and that target doesnt exist. **Fix on host:** run `scripts/fix-gadget-bootcode-on-host.sh` on the host (it removes the symlink and extracts `bootcode4.bin` from the installed rpiboot binary). From your machine: `ssh root@10.130.60.224 'bash -s' < scripts/fix-gadget-bootcode-on-host.sh`. **Alternative:** repopulate the gadget dir with `./scripts/populate-gadget-on-host.sh root@10.130.60.224`, or full reinstall with `./scripts/build-and-deploy-usbboot-to-host.sh root@10.130.60.224`. Then verify: `ls -la /opt/usbboot/mass-storage-gadget64/` (should list a real `bootcode4.bin` or `bootfiles.bin`, plus `boot.img`, `config.txt`).
4. **Clear stuck error in portal** If the portal shows an old error (e.g. "Golden image not found" or "rpiboot failed"), click **Clear message** in the dashboard, or: `ssh root@10.130.60.224 "echo '{\"phase\":\"idle\",\"message\":\"Waiting for reTerminal in boot mode or network.\",\"progress\":null}' > /var/lib/cm4-provisioning/status.json"`. Then unplug/replug the device.
4. **Clear stuck error in portal** If the portal shows an old error (e.g. "Golden image not found" or "rpiboot failed"), click **Clear message** in the dashboard, or: `ssh root@10.130.60.224 "echo '{\"phase\":\"idle\",\"message\":\"Waiting for Device in USB boot mode.\",\"progress\":null}' > /var/lib/cm4-provisioning/status.json"`. Then unplug/replug the device.
5. **"PiShrink not installed" when clicking Shrink/Compress** Shrink and Compress run **on the host**, not in the LXC. Install PiShrink on the host: `ssh root@HOST 'bash -s' < emmc-provisioning/scripts/install-pishrink-on-host.sh`. Ensure deploy has been run so the host has `run-shrink-on-host.sh` and `cm4-shrink.path` enabled (`systemctl status cm4-shrink.path`).

View File

@@ -206,7 +206,7 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
fi
log "Backup complete: $BACKUPS_DIR/$final_name"
write_status "done" "Backup complete: $final_name" "100"
( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) &
( sleep 90 && write_status "idle" "Waiting for Device in USB boot mode." "null" ) &
else
write_status "error" "Backup failed" "null" "dd failed"
fi
@@ -230,7 +230,7 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
log "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal."
write_status "done" "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." "100"
# Auto-reset status to idle after 90s so dashboard does not stay on this message (dashboard also auto-clears after 60s when open)
( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) &
( sleep 90 && write_status "idle" "Waiting for Device in USB boot mode." "null" ) &
else
write_status "error" "Flash failed" "null" "dd failed"
fi
@@ -265,7 +265,7 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
log "EEPROM update written. Remove eMMC disable jumper and power cycle to apply."
write_status "done" "EEPROM update written to boot partition. Remove eMMC disable jumper and power cycle the reTerminal to apply." "100"
rm -f "$EEPROM_UPD" "$EEPROM_SIG"
( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) &
( sleep 90 && write_status "idle" "Waiting for Device in USB boot mode." "null" ) &
else
write_status "error" "EEPROM update failed" "null" "Failed to copy pieeprom.upd/sig"
fi

View File

@@ -2,10 +2,12 @@
Config files for the **provisioning LXC** when using **eth1** as a provisioning LAN (DHCP + TFTP for network boot, NAT for internet).
**LAN subnet:** When you deploy with `DEPLOY_LXC_LAN_SUBNET` (e.g. `10.100.1.1/24`), the deploy script writes `/opt/cm4-provisioning/lan-subnet.conf` inside the LXC with `LAN_GW`, `LAN_CIDR`, and `DHCP_RANGE_START`/`DHCP_RANGE_END`. The setup script and toggle script read this file so dnsmasq, NAT, and PXE options all use the same subnet. If the file is missing, defaults are `10.20.50.1/24` and `10.20.50.100``10.20.50.200`.
| File | Purpose |
|------|--------|
| **dnsmasq-network-boot.conf** | dnsmasq: DHCP + TFTP on eth1 only. Copied to `/etc/dnsmasq.d/` by `scripts/setup-network-boot-on-lxc.sh`. |
| **nft-nat-lan.conf** | nftables NAT so 10.20.50.0/24 uses eth0 for internet. Applied by the setup script to `/etc/nftables.d/nat-lan.conf`. |
| **dnsmasq-network-boot.conf** | Template: dnsmasq DHCP + TFTP on eth1. Setup script writes `/etc/dnsmasq.d/network-boot.conf` using values from `lan-subnet.conf`. |
| **nft-nat-lan.conf** | Template: nftables NAT for LAN→WAN. Setup script writes `/etc/nftables.d/nat-lan.conf` using `LAN_CIDR` from `lan-subnet.conf`. |
Setup is done by running (from your machine):

View File

@@ -1,5 +1,6 @@
# PXE/network-boot DHCP options (option 66 = next-server, 67 = boot file).
# When this file is present, dnsmasq advertises network boot; when removed, devices get DHCP only and boot from local storage.
# Toggle with: /opt/cm4-provisioning/toggle-network-boot-dhcp.sh enable|disable
# Template; toggle script writes the real next-server from /opt/cm4-provisioning/lan-subnet.conf (LAN_GW).
dhcp-option=66,10.20.50.1
dhcp-option=67,start4cd.elf

View File

@@ -1,12 +1,14 @@
# dnsmasq: DHCP + TFTP on eth1 only (provisioning LAN).
# Install to /etc/dnsmasq.d/network-boot.conf on the LXC.
# Restrict to eth1 so we don't interfere with host/other DHCP.
# When using setup-network-boot-on-lxc.sh, the actual subnet and DHCP range
# come from /opt/cm4-provisioning/lan-subnet.conf (written by deploy-to-proxmox.sh).
# Listen only on eth1 (provisioning LAN)
interface=eth1
bind-interfaces
# DHCP range for devices on eth1 (adjust if you use a different subnet)
# DHCP range for devices on eth1 (template; setup script uses lan-subnet.conf)
dhcp-range=10.20.50.100,10.20.50.200,12h
# TFTP for Raspberry Pi / CM4 network boot

View File

@@ -1,6 +1,6 @@
# nftables: NAT for LAN (eth1) so clients use WAN (eth0) for internet.
# Load with: nft -f /etc/nftables.d/nat-lan.conf
# Or use the inline rules in setup-network-boot-on-lxc.sh (no separate file dependency).
# When using setup-network-boot-on-lxc.sh, the subnet is taken from /opt/cm4-provisioning/lan-subnet.conf (LAN_CIDR).
table ip nat {
chain postrouting {

View File

@@ -3,11 +3,19 @@
# When disabled, TFTP is stopped and no boot server is advertised; DHCP still runs.
# Usage: toggle-network-boot-dhcp.sh enable | disable | status
# Run as root. Install to /opt/cm4-provisioning/toggle-network-boot-dhcp.sh
# LAN gateway for TFTP/next-server is read from /opt/cm4-provisioning/lan-subnet.conf (written by deploy-to-proxmox.sh).
set -e
PXE_CONF="/etc/dnsmasq.d/network-boot-pxe.conf"
MAIN_CONF="/etc/dnsmasq.d/network-boot.conf"
LAN_CONF="/opt/cm4-provisioning/lan-subnet.conf"
if [[ -f "$LAN_CONF" ]]; then
source "$LAN_CONF"
else
LAN_GW="10.20.50.1"
fi
# Remove enable-tftp / tftp-root from main config if present (legacy; these belong in PXE conf)
cleanup_main_conf() {
if [ -f "$MAIN_CONF" ] && grep -q 'enable-tftp\|tftp-root' "$MAIN_CONF" 2>/dev/null; then
@@ -18,19 +26,19 @@ cleanup_main_conf() {
case "${1:-}" in
enable)
cleanup_main_conf
cat > "$PXE_CONF" << 'EOF'
cat > "$PXE_CONF" << EOF
# PXE/network boot ENABLED - managed by toggle-network-boot-dhcp.sh
# TFTP server (only active when network boot is enabled)
enable-tftp
tftp-root=/srv/tftpboot
# BOOTP fields (siaddr = TFTP server, filename = boot file)
dhcp-boot=start4cd.elf,,10.20.50.1
dhcp-boot=start4cd.elf,,${LAN_GW}
# DHCP options 66/67 (some PXE clients prefer these)
dhcp-option=66,10.20.50.1
dhcp-option=66,${LAN_GW}
dhcp-option=67,start4cd.elf
EOF
systemctl restart dnsmasq 2>/dev/null || service dnsmasq restart 2>/dev/null || true
echo "Network boot enabled."
echo "Network boot enabled (TFTP next-server: $LAN_GW)."
;;
disable)
cleanup_main_conf

View File

@@ -23,6 +23,12 @@
# DEPLOY_LXC_ROOT_PASSWORD=secret — set root password in LXC and enable SSH
# DEPLOY_LXC_SSH_KEY=/path/to/pub — copy this key to LXC root (default: ~/.ssh/id_ed25519.pub or id_rsa.pub)
# DEPLOY_LOG=1 — also log to deploy-YYYYMMDD-HHMMSS.log
# DEPLOY_LXC_WAN_BRIDGE=vmbr0 — Proxmox bridge for WAN (eth0); default vmbr0
# DEPLOY_LXC_WAN_IP=dhcp — WAN address: dhcp (default) or static e.g. 192.168.1.10/24
# DEPLOY_LXC_LAN_BRIDGE=vmbr1 — If set, add eth1 as LAN on this bridge (e.g. provisioning / network-boot)
# DEPLOY_LXC_LAN_SUBNET=10.20.50.1/24 — LXC IP on LAN (gateway); used only if DEPLOY_LXC_LAN_BRIDGE is set; default 10.20.50.1/24
#
# Legacy: DEPLOY_LXC_NET1="name=eth1,bridge=vmbr1,ip=10.20.50.1/24" still works; overridden by DEPLOY_LXC_LAN_BRIDGE + DEPLOY_LXC_LAN_SUBNET if both are set.
#
# Requires: ssh key access to root@<host>. For full install (usbboot, PiShrink), host needs internet.
@@ -144,8 +150,8 @@ rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git'
log "[4/5] Running remote install (host + LXC) ..."
# Pass optional LXC SSH vars (base64) and selected storage
ssh "$PROXMOX" "ROOTFS_STORAGE='$ROOTFS_STORAGE' CM4_BACKUPS_HOST_PATH='${CM4_BACKUPS_HOST_PATH:-}' DEPLOY_SSH_KEY_B64='${DEPLOY_SSH_KEY_B64:-}' DEPLOY_LXC_PWD_B64='${DEPLOY_LXC_PWD_B64:-}'" bash -s << 'REMOTE'
# Pass optional LXC SSH vars (base64), selected storage, and network (WAN/LAN bridge + subnet)
ssh "$PROXMOX" "ROOTFS_STORAGE='$ROOTFS_STORAGE' CM4_BACKUPS_HOST_PATH='${CM4_BACKUPS_HOST_PATH:-}' DEPLOY_SSH_KEY_B64='${DEPLOY_SSH_KEY_B64:-}' DEPLOY_LXC_PWD_B64='${DEPLOY_LXC_PWD_B64:-}' DEPLOY_LXC_WAN_BRIDGE='${DEPLOY_LXC_WAN_BRIDGE:-}' DEPLOY_LXC_WAN_IP='${DEPLOY_LXC_WAN_IP:-}' DEPLOY_LXC_LAN_BRIDGE='${DEPLOY_LXC_LAN_BRIDGE:-}' DEPLOY_LXC_LAN_SUBNET='${DEPLOY_LXC_LAN_SUBNET:-}' DEPLOY_LXC_NET1='${DEPLOY_LXC_NET1:-}'" bash -s << 'REMOTE'
set -e
DEPLOY=/tmp/emmc-provisioning-deploy
ROOTFS_STORAGE="${ROOTFS_STORAGE:?ROOTFS_STORAGE not set}"
@@ -185,14 +191,24 @@ else
fi
[[ -z "$DEBIAN12_TMPL" ]] && { log "Error: no Debian 12 template found"; exit 1; }
TMPL_NAME=$(basename "$DEBIAN12_TMPL")
# Optional: add eth1 for network-boot LAN (DHCP+TFTP). Set DEPLOY_LXC_NET1 e.g. "name=eth1,bridge=vmbr1,ip=10.20.50.1/24"
# WAN (eth0): bridge and IP from env; default vmbr0 + dhcp
WAN_BRIDGE="${DEPLOY_LXC_WAN_BRIDGE:-vmbr0}"
WAN_IP="${DEPLOY_LXC_WAN_IP:-dhcp}"
# LAN (eth1): optional; use DEPLOY_LXC_LAN_BRIDGE + DEPLOY_LXC_LAN_SUBNET, or legacy DEPLOY_LXC_NET1
NET1_OPT=""
if [[ -n "${DEPLOY_LXC_NET1:-}" ]]; then
if [[ -n "${DEPLOY_LXC_LAN_BRIDGE:-}" ]]; then
LAN_SUBNET="${DEPLOY_LXC_LAN_SUBNET:-10.20.50.1/24}"
NET1_OPT="--net1 name=eth1,bridge=${DEPLOY_LXC_LAN_BRIDGE},ip=${LAN_SUBNET}"
log "LXC network: eth0 WAN bridge=$WAN_BRIDGE ip=$WAN_IP; eth1 LAN bridge=$DEPLOY_LXC_LAN_BRIDGE ip=$LAN_SUBNET"
elif [[ -n "${DEPLOY_LXC_NET1:-}" ]]; then
NET1_OPT="--net1 $DEPLOY_LXC_NET1"
log "LXC network: eth0 WAN bridge=$WAN_BRIDGE ip=$WAN_IP; eth1 from DEPLOY_LXC_NET1"
else
log "LXC network: eth0 WAN bridge=$WAN_BRIDGE ip=$WAN_IP (no LAN interface)"
fi
pct create "$CTID" "local:vztmpl/${TMPL_NAME}" \
--hostname "$LXC_HOSTNAME" --memory 1024 --swap 0 --cores 1 \
--rootfs "${ROOTFS_STORAGE}:8" --net0 name=eth0,bridge=vmbr0,ip=dhcp $NET1_OPT \
--rootfs "${ROOTFS_STORAGE}:8" --net0 name=eth0,bridge="$WAN_BRIDGE",ip="$WAN_IP" $NET1_OPT \
--unprivileged 0 --features nesting=1 -tag cm4-provisioning
mkdir -p /var/lib/cm4-provisioning
pct set "$CTID" -mp0 /var/lib/cm4-provisioning,mp=/var/lib/cm4-provisioning
@@ -302,6 +318,24 @@ fi
log "Starting LXC $CTID if stopped ..."
pct start "$CTID" 2>/dev/null || true
# --- LXC: write lan-subnet.conf when LAN bridge/subnet is set (so dnsmasq/NAT/toggle use same subnet) ---
LAN_SUBNET_FOR_CONF="${DEPLOY_LXC_LAN_SUBNET:-}"
[[ -z "$LAN_SUBNET_FOR_CONF" && -n "${DEPLOY_LXC_LAN_BRIDGE:-}" ]] && LAN_SUBNET_FOR_CONF="10.20.50.1/24"
if [[ -n "$LAN_SUBNET_FOR_CONF" ]]; then
if [[ "$LAN_SUBNET_FOR_CONF" =~ ^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/([0-9]+)$ ]]; then
LAN_GW="${BASH_REMATCH[1]}"
PREFIX="${BASH_REMATCH[2]}"
BASE_3="${LAN_GW%.*}"
LAN_CIDR="${BASE_3}.0/${PREFIX}"
DHCP_RANGE_START="${BASE_3}.100"
DHCP_RANGE_END="${BASE_3}.200"
pct exec "$CTID" -- bash -c "mkdir -p /opt/cm4-provisioning && echo 'LAN_GW=$LAN_GW' > /opt/cm4-provisioning/lan-subnet.conf && echo 'LAN_CIDR=$LAN_CIDR' >> /opt/cm4-provisioning/lan-subnet.conf && echo 'DHCP_RANGE_START=$DHCP_RANGE_START' >> /opt/cm4-provisioning/lan-subnet.conf && echo 'DHCP_RANGE_END=$DHCP_RANGE_END' >> /opt/cm4-provisioning/lan-subnet.conf"
log "LXC: wrote /opt/cm4-provisioning/lan-subnet.conf (LAN_GW=$LAN_GW, LAN_CIDR=$LAN_CIDR, DHCP ${DHCP_RANGE_START}-${DHCP_RANGE_END})"
else
log "Warning: DEPLOY_LXC_LAN_SUBNET=$LAN_SUBNET_FOR_CONF not in form A.B.C.D/PREFIX; skipping lan-subnet.conf"
fi
fi
# --- LXC: flash scripts (for reference; actual flash runs on host) ---
log "LXC: installing flash scripts ..."
pct exec "$CTID" -- mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Mount a Raspberry Pi OS .img or .img.xz, edit cloud-init NoCloud files on the boot
# partition, then unmount and (if .img.xz) recompress. Requires sudo for loop/mount.
#
# Usage:
# ./edit-cloudinit-on-image.sh <path-to-image.img.xz>
# ./edit-cloudinit-on-image.sh <path-to-image.img>
#
# Options:
# --no-recompress If image was .img.xz, leave decompressed .img and do not overwrite
# --replace-with-repo Copy user-data, meta-data, network-config from repo before editing
#
# Example:
# ./edit-cloudinit-on-image.sh /path/to/gnss-bootstrap-20260223-215010.img.xz
#
# Backup the image before running if you want to keep the original.
set -e
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CLOUDINIT_SRC="$REPO_ROOT/emmc-provisioning/cloud-init"
NO_RECOMPRESS=""
REPLACE_WITH_REPO=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-recompress) NO_RECOMPRESS=1; shift ;;
--replace-with-repo) REPLACE_WITH_REPO=1; shift ;;
-*) echo "Unknown option: $1"; exit 1 ;;
*) break ;;
esac
done
IMAGE_IN="$1"
[[ -n "$IMAGE_IN" && -f "$IMAGE_IN" ]] || {
echo "Usage: $0 [--no-recompress] [--replace-with-repo] <path-to-image.img.xz|.img>"
echo "Example: $0 gnss-bootstrap-20260223-215010.img.xz"
exit 1
}
IMAGE_IN="$(realpath "$IMAGE_IN")"
WORK_DIR=""
IMG_FILE=""
ORIGINAL_XZ=""
cleanup() {
if [[ -n "$MNT" && -d "$MNT" ]]; then
sudo umount "$MNT" 2>/dev/null || true
fi
if [[ -n "$LOOP" && -b "$LOOP" ]]; then
sudo losetup -d "$LOOP" 2>/dev/null || true
fi
if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then
if [[ -n "$NO_RECOMPRESS" && -n "$ORIGINAL_XZ" ]]; then
echo "Work dir left at: $WORK_DIR"
else
rm -rf "$WORK_DIR"
fi
fi
}
trap cleanup EXIT
if [[ "$IMAGE_IN" == *.img.xz ]]; then
ORIGINAL_XZ="$IMAGE_IN"
echo "Decompressing $(basename "$ORIGINAL_XZ") (this may take a minute and needs ~3GB free)…"
WORK_DIR=$(mktemp -d -p "${TMPDIR:-/tmp}" edit-cloudinit.XXXXXX)
IMG_FILE="$WORK_DIR/image.img"
xz -T 0 -d -k -f -c "$ORIGINAL_XZ" > "$IMG_FILE"
else
WORK_DIR=$(mktemp -d -p "${TMPDIR:-/tmp}" edit-cloudinit.XXXXXX)
IMG_FILE="$IMAGE_IN"
fi
echo "Attaching image and mounting boot partition…"
LOOP=$(sudo losetup -f --show -P "$IMG_FILE")
# Force kernel to scan partition table so /dev/loopNp1, p2 etc. appear
sudo partprobe "$LOOP" 2>/dev/null || true
sleep 0.5
boot_part="${LOOP}p1"
[[ -b "$boot_part" ]] || boot_part="${LOOP}p2"
[[ -b "$boot_part" ]] || {
echo "Boot partition not found on image. Partitions on image:"
ls -la "${LOOP}"p* 2>/dev/null || true
sudo fdisk -l "$IMG_FILE" 2>/dev/null || true
exit 1
}
MNT="$WORK_DIR/mnt"
mkdir -p "$MNT"
sudo mount "$boot_part" "$MNT"
if [[ -n "$REPLACE_WITH_REPO" && -d "$CLOUDINIT_SRC" ]]; then
echo "Copying cloud-init files from repo into boot partition…"
for f in user-data meta-data network-config; do
if [[ -f "$CLOUDINIT_SRC/$f" ]]; then
sudo cp "$CLOUDINIT_SRC/$f" "$MNT/$f"
elif [[ -f "$CLOUDINIT_SRC/$f.bootstrap" ]]; then
sudo cp "$CLOUDINIT_SRC/$f.bootstrap" "$MNT/$f"
fi
done
fi
echo ""
echo "Boot partition is mounted at: $MNT"
echo "Cloud-init files to edit:"
echo " $MNT/user-data"
echo " $MNT/meta-data"
echo " $MNT/network-config"
echo ""
EDITOR="${EDITOR:-nano}"
echo "Opening editor ($EDITOR). Save and exit when done."
read -r -p "Press Enter to open $EDITOR on these files…"
sudo "$EDITOR" "$MNT/user-data" "$MNT/meta-data" "$MNT/network-config"
echo "Unmounting…"
sudo umount "$MNT"
MNT=""
sudo losetup -d "$LOOP"
LOOP=""
if [[ -n "$ORIGINAL_XZ" && -z "$NO_RECOMPRESS" ]]; then
echo "Recompressing to $(basename "$ORIGINAL_XZ")"
xz -T 0 -z -f -k "$IMG_FILE"
mv -f "${IMG_FILE}.xz" "$ORIGINAL_XZ"
echo "Done. Updated: $ORIGINAL_XZ"
rm -rf "$WORK_DIR"
WORK_DIR=""
elif [[ -n "$NO_RECOMPRESS" && -n "$ORIGINAL_XZ" ]]; then
echo "Left decompressed image at: $IMG_FILE"
echo "Recompress manually: xz -z -k \"$IMG_FILE\""
fi

View File

@@ -25,7 +25,17 @@ if [[ -n "$TARGET" ]]; then
fi
# --- Running inside the LXC from here ---
echo "Configuring network boot (DHCP + TFTP on eth1, NAT via eth0) ..."
# LAN subnet: use /opt/cm4-provisioning/lan-subnet.conf (written by deploy-to-proxmox.sh when DEPLOY_LXC_LAN_SUBNET is set)
LAN_CONF="/opt/cm4-provisioning/lan-subnet.conf"
if [[ -f "$LAN_CONF" ]]; then
source "$LAN_CONF"
else
LAN_GW="10.20.50.1"
LAN_CIDR="10.20.50.0/24"
DHCP_RANGE_START="10.20.50.100"
DHCP_RANGE_END="10.20.50.200"
fi
echo "Configuring network boot (DHCP + TFTP on eth1, NAT via eth0) — LAN $LAN_CIDR (gateway $LAN_GW), DHCP ${DHCP_RANGE_START}-${DHCP_RANGE_END} ..."
# 1) Install dnsmasq
if ! command -v dnsmasq >/dev/null 2>&1; then
@@ -34,12 +44,12 @@ fi
# 2) dnsmasq config for eth1 only (DHCP + TFTP); PXE options in network-boot-pxe.conf (toggle with toggle-network-boot-dhcp.sh)
mkdir -p /etc/dnsmasq.d
cat > /etc/dnsmasq.d/network-boot.conf << 'DNSMASQ'
cat > /etc/dnsmasq.d/network-boot.conf << DNSMASQ
# DHCP on eth1 only (provisioning LAN)
# TFTP and PXE options are in network-boot-pxe.conf, controlled by toggle-network-boot-dhcp.sh
interface=eth1
bind-interfaces
dhcp-range=10.20.50.100,10.20.50.200,12h
dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},12h
log-dhcp
log-queries
port=0
@@ -89,14 +99,14 @@ fi
echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-cm4-network-boot.conf
sysctl -p /etc/sysctl.d/99-cm4-network-boot.conf 2>/dev/null || sysctl -w net.ipv4.ip_forward=1
# 5) NAT: 10.20.50.0/24 -> eth0 (masquerade)
# 5) NAT: LAN subnet -> eth0 (masquerade)
if command -v nft >/dev/null 2>&1; then
mkdir -p /etc/nftables.d
cat > /etc/nftables.d/nat-lan.conf << 'NFT'
cat > /etc/nftables.d/nat-lan.conf << NFT
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.20.50.0/24 oifname "eth0" masquerade
ip saddr ${LAN_CIDR} oifname "eth0" masquerade
}
}
NFT
@@ -110,8 +120,8 @@ NFT
echo "NAT rule added (nftables) and saved to /etc/nftables.d/nat-lan.conf"
else
# Fallback iptables
iptables -t nat -C POSTROUTING -s 10.20.50.0/24 -o eth0 -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s 10.20.50.0/24 -o eth0 -j MASQUERADE
iptables -t nat -C POSTROUTING -s "${LAN_CIDR}" -o eth0 -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s "${LAN_CIDR}" -o eth0 -j MASQUERADE
echo "NAT rule added (iptables)."
fi
@@ -120,6 +130,6 @@ systemctl enable dnsmasq
systemctl restart dnsmasq