Remove obsolete audio and buzzer control documentation files, including detailed guides and HTML interfaces, to streamline the repository and eliminate redundancy. This cleanup enhances maintainability and focuses on essential resources for the reTerminal DM4 audio and buzzer functionalities.
This commit is contained in:
55
emmc-provisioning/README.md
Normal file
55
emmc-provisioning/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# reTerminal DM4 eMMC provisioning
|
||||
|
||||
Automatically **deploy** or **backup** the CM4 eMMC when the reTerminal is connected in **USB boot mode** or when it **boots over the network**. Uses **cloud-init** for first-boot configuration.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
emmc-provisioning/
|
||||
├── README.md ← You are here
|
||||
├── docs/ Documentation
|
||||
│ ├── 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
|
||||
│ └── 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
|
||||
│ ├── cm4-flash-trigger.sh Called by udev; starts the flash job
|
||||
│ ├── build-cloudinit-image.sh Build golden image with cloud-init
|
||||
│ └── run-shrink-on-host.sh PiShrink (optional)
|
||||
├── scripts/ Deployment and one-off scripts
|
||||
│ ├── deploy-to-proxmox.sh Deploy to Proxmox host + LXC
|
||||
│ ├── sync-portal-files-to-lxc.sh Sync cloud-init/first-boot assets to file server (LXC)
|
||||
│ ├── setup-network-boot-on-lxc.sh Configure PXE/dnsmasq for network boot
|
||||
│ ├── install-usbboot-on-host.sh Build and install rpiboot on the host
|
||||
│ └── ... (other deploy/monitor scripts)
|
||||
├── dashboard/ Flask web UI (runs in LXC or standalone)
|
||||
│ ├── app.py
|
||||
│ └── README.md
|
||||
├── cloud-init/ First-boot and file-server assets
|
||||
│ ├── first-boot.sh Main first-boot script (run by cloud-init)
|
||||
│ ├── first-boot.md Documentation for first-boot.sh
|
||||
│ ├── start-chromium.sh Chromium kiosk launcher (→ file server)
|
||||
│ ├── user-data-remote-gnss.example Example cloud-init user-data (curl first-boot.sh)
|
||||
│ ├── config-files/ LightDM, Maliit, one-shots (.desktop + scripts)
|
||||
│ │ ├── chromium-kiosk.desktop
|
||||
│ │ ├── set-rotation-once.desktop
|
||||
│ │ └── ...
|
||||
│ ├── files-from-guard/ Plymouth, splash assets; README of required files
|
||||
│ └── fix-reterminal-display.sh One-time fix script (splash, rotation, wallpaper)
|
||||
├── lxc/ LXC config snippets (dnsmasq, nftables)
|
||||
└── network-client/ For network-booted devices
|
||||
├── provisioning-client.sh Register + poll, then Deploy or Backup
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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**.
|
||||
@@ -0,0 +1,3 @@
|
||||
[Seat:*]
|
||||
user-session=rpd-labwc
|
||||
autologin-session=rpd-labwc
|
||||
@@ -0,0 +1,3 @@
|
||||
[greeter]
|
||||
wallpaper=/usr/share/rpd-wallpaper/splash.png
|
||||
wallpaper_mode=crop
|
||||
13
emmc-provisioning/cloud-init/config-files/README.md
Normal file
13
emmc-provisioning/cloud-init/config-files/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Config files for first-boot (file server)
|
||||
|
||||
first-boot.sh downloads these from `FILE_SERVER` (e.g. `http://10.130.60.141:5000/files/first-boot`) and installs them to the paths below. Upload each file into the **first-boot** subfolder of portal-files (e.g. `/var/lib/cm4-provisioning/portal-files/first-boot/`).
|
||||
|
||||
| File on server | Destination on device |
|
||||
|----------------|------------------------|
|
||||
| chromium-kiosk.desktop | /home/pi/.config/autostart/chromium-kiosk.desktop (with start-chromium.sh) |
|
||||
| 99-wallpaper.conf | /etc/lightdm/lightdm.conf.d/99-wallpaper.conf |
|
||||
| 99-default-session.conf | /etc/lightdm/lightdm.conf.d/99-default-session.conf (rpd-labwc) |
|
||||
| maliit-keyboard.desktop | /home/pi/.config/autostart/maliit-keyboard.desktop |
|
||||
| set-rotation-once.desktop | /home/pi/.config/autostart/set-rotation-once.desktop (with set-rotation-once.sh) |
|
||||
|
||||
Wallpaper is set once during first-boot via pcmanfm config; no set-wallpaper-once one-shot.
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Chromium Fullscreen
|
||||
Exec=/home/pi/start-chromium.sh
|
||||
Hidden=false
|
||||
NoDisplay=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
2
emmc-provisioning/cloud-init/config-files/kdeglobals
Normal file
2
emmc-provisioning/cloud-init/config-files/kdeglobals
Normal file
@@ -0,0 +1,2 @@
|
||||
[General]
|
||||
ForceFontDPI=120
|
||||
4
emmc-provisioning/cloud-init/config-files/kwinrc
Normal file
4
emmc-provisioning/cloud-init/config-files/kwinrc
Normal file
@@ -0,0 +1,4 @@
|
||||
[Windows]
|
||||
BorderlessMaximizedWindows=true
|
||||
[Plugins]
|
||||
touchpointsEnabled=true
|
||||
@@ -0,0 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Maliit Keyboard
|
||||
Exec=maliit-keyboard -r
|
||||
X-GNOME-Autostart-enabled=true
|
||||
@@ -0,0 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Set rotation once
|
||||
Exec=/home/pi/set-rotation-once.sh
|
||||
X-GNOME-Autostart-enabled=true
|
||||
@@ -0,0 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Set wallpaper once
|
||||
Exec=/home/pi/set-wallpaper-once.sh
|
||||
X-GNOME-Autostart-enabled=true
|
||||
30
emmc-provisioning/cloud-init/files-from-guard/README.md
Normal file
30
emmc-provisioning/cloud-init/files-from-guard/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Files for the file server
|
||||
|
||||
first-boot.sh downloads from **`.../files/first-boot/`** (e.g. `http://10.130.60.141:5000/files/first-boot`). Put all first-boot assets in a **first-boot** subfolder of portal-files so provisioning works.
|
||||
|
||||
## Required files (host under `.../files/first-boot/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| **start-chromium.sh** | Chromium kiosk launcher (executable). |
|
||||
| **chromium-kiosk.desktop** | Autostart entry for the kiosk. |
|
||||
| **splash.png** | Boot splash + login + desktop wallpaper (single image). |
|
||||
| **custom.plymouth** | Plymouth theme config (from `plymouth-custom/`). |
|
||||
| **custom.script** | Plymouth script that displays splash.png (from `plymouth-custom/`). |
|
||||
| **99-wallpaper.conf** | LightDM greeter wallpaper (from `config-files/`). |
|
||||
| **99-default-session.conf** | LightDM default session rpd-labwc (from `config-files/`). |
|
||||
| **maliit-keyboard.desktop** | Maliit on-screen keyboard autostart (from `config-files/`). |
|
||||
| **set-rotation-once.sh** + **.desktop** | One-shot: wlr-randr rotation (Left) at first login. |
|
||||
|
||||
Desktop wallpaper is set once during first-boot via pcmanfm config (first-boot.sh); no set-wallpaper-once one-shot needed.
|
||||
|
||||
---
|
||||
|
||||
## What’s in this folder
|
||||
|
||||
- **plymouth-custom/splash.png** — Example splash image; host as `splash.png`.
|
||||
- **plymouth-custom/custom.plymouth** — Plymouth theme definition; host as `custom.plymouth`.
|
||||
- **plymouth-custom/custom.script** — Plymouth script that draws splash.png; host as `custom.script`.
|
||||
- **lightdm/RPiSystem_dark.png** — Unused; only `splash.png` is used now.
|
||||
- **start-chromium.sh** and **chromium-kiosk.desktop** live in `cloud-init/` and `cloud-init/config-files/`; `scripts/sync-portal-files-to-lxc.sh` copies them to the portal first-boot folder.
|
||||
- **set-rotation-once.sh** (and its .desktop) are in `cloud-init/` and synced to `portal-files/first-boot/` by the same script.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
@@ -0,0 +1,8 @@
|
||||
[Plymouth Theme]
|
||||
Name=Custom Splash
|
||||
Description=Custom boot splash screen
|
||||
ModuleName=script
|
||||
|
||||
[script]
|
||||
ImageDir=/usr/share/plymouth/themes/custom
|
||||
ScriptFile=/usr/share/plymouth/themes/custom/custom.script
|
||||
@@ -0,0 +1,40 @@
|
||||
screen_width = Window.GetWidth();
|
||||
screen_height = Window.GetHeight();
|
||||
|
||||
theme_image = Image("splash.png");
|
||||
image_width = theme_image.GetWidth();
|
||||
image_height = theme_image.GetHeight();
|
||||
|
||||
scale_x = image_width / screen_width;
|
||||
scale_y = image_height / screen_height;
|
||||
|
||||
if (scale_x > 1 || scale_y > 1)
|
||||
{
|
||||
if (scale_x > scale_y)
|
||||
{
|
||||
resized_image = theme_image.Scale(screen_width, image_height / scale_x);
|
||||
image_x = 0;
|
||||
image_y = (screen_height - ((image_height * screen_width) / image_width)) / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
resized_image = theme_image.Scale(image_width / scale_y, screen_height);
|
||||
image_x = (screen_width - ((image_width * screen_height) / image_height)) / 2;
|
||||
image_y = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
resized_image = theme_image.Scale(image_width, image_height);
|
||||
image_x = (screen_width - image_width) / 2;
|
||||
image_y = (screen_height - image_height) / 2;
|
||||
}
|
||||
|
||||
if (Plymouth.GetMode() != "shutdown")
|
||||
{
|
||||
sprite = Sprite(resized_image);
|
||||
sprite.SetPosition(image_x, image_y, -100);
|
||||
}
|
||||
|
||||
fun message_callback(text) {
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
155
emmc-provisioning/cloud-init/first-boot.md
Normal file
155
emmc-provisioning/cloud-init/first-boot.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# first-boot.sh — Documentation
|
||||
|
||||
This script runs once on first boot via cloud-init (see `user-data-remote-gnss.example`). It installs packages, configures a Chromium kiosk with rpd-labwc (Raspberry Pi Desktop + labwc) and touch support, and installs the reTerminal DM display/touch drivers. It must run as **root**.
|
||||
|
||||
---
|
||||
|
||||
## Structure (sections)
|
||||
|
||||
1. **Constants** — `FILE_SERVER`, `PI_USER`, paths, log file.
|
||||
2. **Logging** — All output tee’d to `/var/log/first-boot.log`.
|
||||
3. **Helpers** — `install_oneshot(name)` downloads `${name}.sh` from the file server and installs it as a one-shot autostart (runs once at pi’s first login, then deletes itself).
|
||||
4. **Packages** — git, Chromium, wmctrl, SSH, swaybg, wlr-randr, maliit, xinput-calibrator, rpi-eeprom.
|
||||
5. **Kiosk files** — Download `start-chromium.sh` and `chromium-kiosk.desktop`; create autostart dir.
|
||||
6. **Boot splash and wallpaper** — Download `splash.png`; install Plymouth custom theme; copy image for LightDM and desktop.
|
||||
7. **LightDM** — Download `99-default-session.conf` (rpd-labwc) and `99-wallpaper.conf` to `/etc/lightdm/lightdm.conf.d/`.
|
||||
8. **Maliit** — Download `maliit-keyboard.desktop` from file server to pi’s autostart.
|
||||
9. **reTerminal DM drivers** — Seeed repo clone and `reTerminal.sh`.
|
||||
10. **Re-apply splash** — Set `disable_splash=0`, Plymouth theme to `custom` only, `update-initramfs`.
|
||||
11. **Dark theme** — Set GTK dark theme for user `pi`: `~/.config/gtk-3.0/settings.ini` with `gtk-application-prefer-dark-theme=1` and `gtk-theme-name=PiXnoir` (Raspberry Pi OS dark theme).
|
||||
12. **CM4 EEPROM enable** — On CM4, `rpi-eeprom-update` is disabled by default. First-boot enables it by: adding `RPI_EEPROM_USE_FLASHROM=1` and `CM4_ENABLE_RPI_EEPROM_UPDATE=1` to `/etc/default/rpi-eeprom-update`; adding a `[cm4]` block to `config.txt` with `dtparam=spi=on`, `dtoverlay=audremap`, `dtoverlay=spi-gpio40-45`. After reboot, `rpi-eeprom-update -l` works and boot order can be set.
|
||||
13. **Boot order** — If `rpi-eeprom-config` is available, set `BOOT_ORDER=0x21` (network first, then eMMC/SD). On CM4 first boot this may be skipped (EEPROM not yet enabled); a one-shot systemd service runs after reboot to set boot order once.
|
||||
14. **One-shots** — Download `set-rotation-once.sh` + `.desktop` from file server (wlr-randr for labwc). Wallpaper is set once via pcmanfm config during first-boot.
|
||||
15. **Reboot.**
|
||||
|
||||
---
|
||||
|
||||
## Script header and environment
|
||||
|
||||
- **`set -e`** — Exit immediately if any command fails.
|
||||
- **`DEBIAN_FRONTEND=noninteractive`** — Prevents apt from asking questions (assumes default or automatic answers).
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
All script output (stdout and stderr) is appended to **`/var/log/first-boot.log`** so you can review what ran after first boot (e.g. over SSH: `cat /var/log/first-boot.log`).
|
||||
|
||||
- **`LOGFILE`** — Path of the log file (`/var/log/first-boot.log`).
|
||||
- **`log "..."`** — Prints a timestamped line (ISO format); used for section headers.
|
||||
- **`exec > >(tee -a "$LOGFILE") 2>&1`** — Sends all subsequent stdout and stderr to both the console and the log file.
|
||||
|
||||
---
|
||||
|
||||
## Packages
|
||||
|
||||
Installs the software needed for the rest of the script and for the kiosk:
|
||||
|
||||
| Package | Purpose |
|
||||
|--------|---------|
|
||||
| **git** | Clone the Seeed Linux DTOverlays repo for reTerminal DM drivers. |
|
||||
| **chromium-browser** | Full-screen kiosk browser. |
|
||||
| **wmctrl** | Window control; used to force Chromium into fullscreen. |
|
||||
| **openssh-server** | SSH access (often also enabled in user-data). |
|
||||
| **swaybg** | Wallpaper for labwc (Wayland); used by one-shot and labwc autostart. |
|
||||
| **wlr-randr** | Display rotation for wlroots/labwc; one-shot sets “Left” (transform 270). |
|
||||
| **maliit-keyboard** | On-screen keyboard for touch input. |
|
||||
| **xinput-calibrator** | Touchscreen calibration (optional; run manually if needed). |
|
||||
| **rpi-eeprom** | EEPROM tools (`rpi-eeprom-update`, `rpi-eeprom-config`) for Pi 4/CM4 boot order (e.g. network first). |
|
||||
|
||||
---
|
||||
|
||||
## Autostart directory
|
||||
|
||||
Creates `/home/pi/.config/autostart` so that `.desktop` files placed there are started when user `pi` logs into the graphical session.
|
||||
|
||||
---
|
||||
|
||||
## Chromium kiosk files (from file server)
|
||||
|
||||
Downloads from `FILE_SERVER` (no local creation):
|
||||
|
||||
- **`FILE_SERVER`** — Base URL for first-boot assets (default: `http://10.130.60.141:5000/files/first-boot`). All first-boot files are served from a **first-boot** subfolder on the file server. Change this if your server or path is different.
|
||||
- **`start-chromium.sh`** — Downloaded to `/home/pi/start-chromium.sh`, made executable (755), owned by `pi`. This script waits for the desktop, starts Chromium in kiosk mode (e.g. `--app=...`), and uses `wmctrl` to force fullscreen.
|
||||
- **`chromium-kiosk.desktop`** — Downloaded to `/home/pi/.config/autostart/chromium-kiosk.desktop`, mode 644, owned by `pi`. This autostart entry runs `start-chromium.sh` when `pi` logs in.
|
||||
|
||||
Ensure the `.desktop` file on the server has `Exec=/home/pi/start-chromium.sh` (or the path you use on the device).
|
||||
|
||||
---
|
||||
|
||||
## Boot splash and wallpaper (single image from file server)
|
||||
|
||||
A **single image** (`splash.png`) is used for the boot splash, login screen, and desktop wallpaper. Host it at **`${FILE_SERVER}/splash.png`** (e.g. `http://10.130.60.141:5000/files/splash.png`).
|
||||
|
||||
- **Plymouth (boot splash):** Downloads `splash.png`, `custom.plymouth`, and `custom.script` from the file server → installs to `/usr/share/plymouth/themes/custom/` → sets `Theme=custom` in `/etc/plymouth/plymouthd.conf` (single `[Daemon]` section) → runs `update-initramfs -u`. If any download fails, logs a warning and continues. **Note:** On reTerminal DM the DSI panel can initialize a few seconds into boot, so the Plymouth splash may appear briefly or after a short black screen; this is normal for DSI displays.
|
||||
- **LightDM (login screen):** Copies the same image to `/usr/share/rpd-wallpaper/splash.png` and writes `/etc/lightdm/lightdm.conf.d/99-wallpaper.conf` with `wallpaper=...` and `wallpaper_mode=crop`.
|
||||
- **Desktop wallpaper:** First-boot sets the wallpaper once by writing pcmanfm config: `~/.config/pcmanfm/LXDE-pi/desktop-items-0.conf` and `default/desktop-items-0.conf` with `wallpaper=/usr/share/rpd-wallpaper/splash.png` and `wallpaper_mode=crop`. pcmanfm-pi (rpd-labwc desktop) reads this and shows the wallpaper; no autostart script. To set or change wallpaper manually: `pcmanfm --set-wallpaper /path/to/image.png --wallpaper-mode=crop` (modes: crop, stretch, fit, center, tile, screen, color).
|
||||
|
||||
---
|
||||
|
||||
## LightDM: default session (rpd-labwc)
|
||||
|
||||
Writes `/etc/lightdm/lightdm.conf.d/99-default-session.conf` so the display manager (LightDM) uses the **rpd-labwc** session (Raspberry Pi Desktop with labwc Wayland compositor). The script also patches `/etc/lightdm/lightdm.conf` so `user-session` and `autologin-session` are `rpd-labwc`.
|
||||
|
||||
---
|
||||
|
||||
## On-screen keyboard (Maliit)
|
||||
|
||||
Creates `/home/pi/.config/autostart/maliit-keyboard.desktop` so that **Maliit** (`maliit-keyboard -r`) starts when `pi` logs in. This gives an on-screen keyboard for touch-only use.
|
||||
|
||||
---
|
||||
|
||||
## Ownership for pi’s config
|
||||
|
||||
Runs `chown -R pi:pi /home/pi/.config` so all files under `pi`’s config directory are owned by `pi`. Ensures the desktop session runs as `pi` without permission issues.
|
||||
|
||||
---
|
||||
|
||||
## reTerminal DM: Seeed display/touch drivers
|
||||
|
||||
Installs the official Seeed drivers for the reTerminal DM so the display and touch work:
|
||||
|
||||
1. Clones **https://github.com/Seeed-Studio/seeed-linux-dtoverlays** into `/tmp/seeed-linux-dtoverlays` (`--depth 1` for a shallow clone).
|
||||
2. Runs **`scripts/reTerminal.sh --device reTerminal-DM`** to install device-tree overlays and any required firmware/config for the reTerminal DM.
|
||||
3. Removes the clone from `/tmp`.
|
||||
|
||||
These changes take effect after a reboot.
|
||||
|
||||
---
|
||||
|
||||
## Screen rotation (portrait → landscape, “Left”)
|
||||
|
||||
The reTerminal DM default is portrait. Rotation is set using **wlr-randr** (labwc/Wayland), so nothing is written to `/boot/firmware/config.txt`.
|
||||
|
||||
- **One-shot autostart** — A small script runs once when user `pi` first logs into the graphical session:
|
||||
- **`/home/pi/set-rotation-once.sh`** — Waits 5 seconds, detects the output name with `wlr-randr`, then runs `wlr-randr --output <name> --transform 270` (Left / 90° counter-clockwise). Falls back to `DSI-1` if detection fails.
|
||||
- **`/home/pi/.config/autostart/set-rotation-once.desktop`** — Autostart entry that runs the script once at first login. Both the script and this desktop file **delete themselves** after a successful run, so rotation is applied only on first boot and never again.
|
||||
- **Effect** — Same as choosing “Left” in display settings. Rotation applies after the first login; no reboot needed for rotation (only for the Seeed drivers).
|
||||
|
||||
---
|
||||
|
||||
## Dark theme
|
||||
|
||||
First-boot sets a dark GTK theme for user **pi** via **`~/.config/gtk-3.0/settings.ini`** with **`gtk-application-prefer-dark-theme=1`** and **`gtk-theme-name=PiXnoir`**. On older images use **Adwaita-dark** if PiXnoir is missing.
|
||||
|
||||
## CM4: enable rpi-eeprom-update (for boot order)
|
||||
|
||||
On **CM4**, first-boot enables `rpi-eeprom-update` by: (1) **`/etc/default/rpi-eeprom-update`**: **`RPI_EEPROM_USE_FLASHROM=1`**, **`CM4_ENABLE_RPI_EEPROM_UPDATE=1`**; (2) **config.txt** **`[cm4]`** block: **`dtparam=spi=on`**, **`dtoverlay=audremap`**, **`dtoverlay=spi-gpio40-45`**. After reboot, **`rpi-eeprom-update -l`** works. See: [usbboot](https://github.com/raspberrypi/usbboot/blob/master/Readme.md).
|
||||
|
||||
## Boot order (network first, then eMMC/SD)
|
||||
|
||||
If **`rpi-eeprom-config`** and **`rpi-eeprom-update`** are present (Pi 4/CM4), the script sets the EEPROM **`BOOT_ORDER=0x21`**: try **network** first (0x2), then **SD/eMMC** (0x1). **Pi 4:** applied on first-boot; EEPROM update scheduled for next reboot. **CM4:** a one-shot service (**`set-cm4-boot-order-once.service`**) runs after the next boot and sets BOOT_ORDER=0x21, then removes itself (two reboots for network-first). If “Could not read current EEPROM config” appears, run `sudo rpi-eeprom-update -l` on the device to see if a firmware file is listed; you can set boot order manually with `rpi-eeprom-config` if needed. If the tools are not available, the step is skipped.
|
||||
|
||||
## Reboot
|
||||
|
||||
Runs **`reboot`** so the kernel and display stack load the new Seeed drivers. After reboot, the screen and touch work; on **pi**’s first login the one-shot sets rotation to “Left” (landscape), and the Chromium kiosk and Maliit start via autostart.
|
||||
|
||||
---
|
||||
|
||||
## Customisation
|
||||
|
||||
- **File server** — Edit `FILE_SERVER` if your assets are served from another host/port. Host all files listed in **`files-from-guard/README.md`** and **`config-files/README.md`** (kiosk, splash, Plymouth, LightDM, Maliit, one-shots and their .desktop files).
|
||||
- **Kiosk URL** — The URL Chromium opens is defined in `start-chromium.sh` on your file server (e.g. `--app=http://127.0.0.1:8080`); change it there.
|
||||
- **User** — If you use a user other than `pi`, replace `pi` in this script and in the files on the file server (paths and ownership).
|
||||
- **Screen rotation** — The one-shot runs `wlr-randr --output <name> --transform 270` (Left). To use another orientation, change `270` to `90` (right), `180` (inverted), or `normal`.
|
||||
- **Desktop wallpaper** — First-boot writes `wallpaper=` and `wallpaper_mode=crop` into pcmanfm’s `desktop-items-0.conf`. To change later: `pcmanfm --set-wallpaper /path/to/image --wallpaper-mode=crop`. **Other pcmanfm options:** `pcmanfm --desktop` (start desktop manager), `pcmanfm --desktop-pref` (open desktop preferences GUI), `pcmanfm --desktop-off` (stop desktop manager), `pcmanfm -w FILE` (short form of --set-wallpaper). Wallpaper modes: `crop`, `stretch`, `fit`, `center`, `tile`, `screen`, `color`.
|
||||
288
emmc-provisioning/cloud-init/first-boot.sh
Normal file
288
emmc-provisioning/cloud-init/first-boot.sh
Normal file
@@ -0,0 +1,288 @@
|
||||
#!/bin/bash
|
||||
# First-boot: packages, Chromium kiosk, rpd-labwc + touch, reTerminal DM drivers.
|
||||
# Run by cloud-init (user-data-remote-gnss.example). Run as root.
|
||||
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# --- Constants ---
|
||||
# All first-boot assets live in portal-files/first-boot/ on the file server.
|
||||
FILE_SERVER="http://10.130.60.141:5000/files/first-boot"
|
||||
HOSTNAME="guard"
|
||||
PI_USER="pi"
|
||||
PI_HOME="/home/$PI_USER"
|
||||
AUTOSTART="$PI_HOME/.config/autostart"
|
||||
LOGFILE="/var/log/first-boot.log"
|
||||
PLYMOUTH_DIR="/usr/share/plymouth/themes/custom"
|
||||
WALLPAPER_PATH="/usr/share/rpd-wallpaper/splash.png"
|
||||
|
||||
# --- Logging ---
|
||||
log() { echo "[$(date -Iseconds)] $*"; }
|
||||
exec > >(tee -a "$LOGFILE") 2>&1
|
||||
log "=== first-boot.sh started ==="
|
||||
log "FILE_SERVER=$FILE_SERVER PI_USER=$PI_USER LOGFILE=$LOGFILE"
|
||||
|
||||
# --- 0. Hostname and /etc/hosts (avoids "unable to resolve host" with sudo) ---
|
||||
log "--- Hostname: $HOSTNAME ---"
|
||||
echo "$HOSTNAME" > /etc/hostname
|
||||
hostnamectl set-hostname "$HOSTNAME" 2>/dev/null || true
|
||||
# Ensure hostname resolves so sudo and other tools don't warn
|
||||
if ! grep -q "127.0.1.1[[:space:]]*$HOSTNAME" /etc/hosts 2>/dev/null; then
|
||||
sed -i "/127.0.1.1[[:space:]].*$/d" /etc/hosts
|
||||
echo "127.0.1.1 $HOSTNAME" >> /etc/hosts
|
||||
fi
|
||||
log "Hostname set to $HOSTNAME; /etc/hosts updated"
|
||||
|
||||
# --- Helpers ---
|
||||
# Download script + .desktop from FILE_SERVER and install as one-shot autostart (runs once at pi's first login, then deletes itself).
|
||||
install_oneshot() {
|
||||
local name="$1"
|
||||
log "--- Installing one-shot: $name ---"
|
||||
if curl -fsSL "${FILE_SERVER}/${name}.sh" -o "$PI_HOME/${name}.sh"; then
|
||||
log "Downloaded ${name}.sh to $PI_HOME/${name}.sh"
|
||||
else
|
||||
log "WARNING: Could not download ${name}.sh"; return 1
|
||||
fi
|
||||
if curl -fsSL "${FILE_SERVER}/${name}.desktop" -o "$AUTOSTART/${name}.desktop"; then
|
||||
log "Downloaded ${name}.desktop to $AUTOSTART/${name}.desktop"
|
||||
else
|
||||
log "WARNING: Could not download ${name}.desktop"; return 1
|
||||
fi
|
||||
chmod 755 "$PI_HOME/${name}.sh" && chmod 644 "$AUTOSTART/${name}.desktop"
|
||||
chown "$PI_USER:$PI_USER" "$PI_HOME/${name}.sh" "$AUTOSTART/${name}.desktop"
|
||||
log "One-shot $name installed (will run at first login and then remove itself)"
|
||||
}
|
||||
|
||||
# --- 1. Packages ---
|
||||
log "--- Installing packages ---"
|
||||
log "Running apt-get update ..."
|
||||
apt-get update -qq
|
||||
log "Installing: git chromium wmctrl openssh-server swaybg wlr-randr maliit-keyboard xinput-calibrator rpi-eeprom"
|
||||
apt-get install -y -qq git chromium wmctrl openssh-server \
|
||||
swaybg wlr-randr maliit-keyboard xinput-calibrator rpi-eeprom
|
||||
log "Packages installed successfully"
|
||||
|
||||
# --- 2. Dirs and kiosk files from file server ---
|
||||
log "--- Kiosk files ---"
|
||||
log "Creating $AUTOSTART"
|
||||
mkdir -p "$AUTOSTART"
|
||||
log "Downloading start-chromium.sh from ${FILE_SERVER}/start-chromium.sh"
|
||||
curl -fsSL "${FILE_SERVER}/start-chromium.sh" -o "$PI_HOME/start-chromium.sh"
|
||||
log "Downloading chromium-kiosk.desktop from ${FILE_SERVER}/chromium-kiosk.desktop"
|
||||
curl -fsSL "${FILE_SERVER}/chromium-kiosk.desktop" -o "$AUTOSTART/chromium-kiosk.desktop"
|
||||
chmod 755 "$PI_HOME/start-chromium.sh" && chmod 644 "$AUTOSTART/chromium-kiosk.desktop"
|
||||
chown -R "$PI_USER:$PI_USER" "$PI_HOME/start-chromium.sh" "$AUTOSTART/chromium-kiosk.desktop"
|
||||
log "Kiosk files installed under $PI_HOME and $AUTOSTART"
|
||||
|
||||
# --- 3. Boot splash and wallpaper (splash.png + Plymouth theme from file server) ---
|
||||
log "--- Boot splash and wallpaper ---"
|
||||
log "Creating $PLYMOUTH_DIR and /usr/share/rpd-wallpaper"
|
||||
mkdir -p "$PLYMOUTH_DIR" /usr/share/rpd-wallpaper
|
||||
if curl -fsSL "${FILE_SERVER}/splash.png" -o "$PLYMOUTH_DIR/splash.png"; then
|
||||
log "Downloaded splash.png; copying to $WALLPAPER_PATH"
|
||||
cp "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
|
||||
chmod 644 "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
|
||||
if curl -fsSL "${FILE_SERVER}/custom.plymouth" -o "$PLYMOUTH_DIR/custom.plymouth" \
|
||||
&& curl -fsSL "${FILE_SERVER}/custom.script" -o "$PLYMOUTH_DIR/custom.script"; then
|
||||
chmod 644 "$PLYMOUTH_DIR/custom.plymouth" "$PLYMOUTH_DIR/custom.script"
|
||||
log "Plymouth theme files (custom.plymouth, custom.script) installed"
|
||||
else
|
||||
log "WARNING: Could not download custom.plymouth/custom.script; boot splash theme may be incomplete"
|
||||
fi
|
||||
grep -q '^Theme=custom' /etc/plymouth/plymouthd.conf 2>/dev/null || printf '%s\n' '[Daemon]' 'Theme=custom' >> /etc/plymouth/plymouthd.conf
|
||||
log "Running update-initramfs (may take a moment) ..."
|
||||
update-initramfs -u -k all 2>/dev/null || true
|
||||
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||
curl -fsSL "${FILE_SERVER}/99-wallpaper.conf" -o /etc/lightdm/lightdm.conf.d/99-wallpaper.conf 2>/dev/null || log "WARNING: Could not download 99-wallpaper.conf"
|
||||
# Set desktop wallpaper once via pcmanfm config (rpd-labwc uses pcmanfm-pi; profile LXDE-pi or default)
|
||||
for PROFILE in LXDE-pi default; do
|
||||
PCMANFM_DESKTOP="$PI_HOME/.config/pcmanfm/$PROFILE/desktop-items-0.conf"
|
||||
mkdir -p "$(dirname "$PCMANFM_DESKTOP")"
|
||||
if [[ ! -f "$PCMANFM_DESKTOP" ]]; then
|
||||
printf '%s\n' '[*]' "wallpaper=$WALLPAPER_PATH" 'wallpaper_mode=crop' 'wallpaper_common=1' > "$PCMANFM_DESKTOP"
|
||||
else
|
||||
grep -q '^wallpaper=' "$PCMANFM_DESKTOP" && sed -i "s|^wallpaper=.*|wallpaper=$WALLPAPER_PATH|" "$PCMANFM_DESKTOP" || echo "wallpaper=$WALLPAPER_PATH" >> "$PCMANFM_DESKTOP"
|
||||
grep -q '^wallpaper_mode=' "$PCMANFM_DESKTOP" && sed -i 's/^wallpaper_mode=.*/wallpaper_mode=crop/' "$PCMANFM_DESKTOP" || echo 'wallpaper_mode=crop' >> "$PCMANFM_DESKTOP"
|
||||
fi
|
||||
chown -R "$PI_USER:$PI_USER" "$(dirname "$PCMANFM_DESKTOP")"
|
||||
done
|
||||
log "Set desktop wallpaper via pcmanfm config (LXDE-pi and default)"
|
||||
log "Splash and wallpaper set from file server"
|
||||
else
|
||||
log "WARNING: Could not download splash.png"
|
||||
fi
|
||||
|
||||
# --- 4. LightDM: rpd-labwc session + configs from file server ---
|
||||
log "--- LightDM session (rpd-labwc) ---"
|
||||
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||
if curl -fsSL "${FILE_SERVER}/99-default-session.conf" -o /etc/lightdm/lightdm.conf.d/99-default-session.conf 2>/dev/null; then
|
||||
log "99-default-session.conf installed"
|
||||
else
|
||||
log "WARNING: Could not download 99-default-session.conf"
|
||||
fi
|
||||
# Raspberry Pi OS may apply main lightdm.conf after .conf.d; force session in main config too
|
||||
if [[ -f /etc/lightdm/lightdm.conf ]]; then
|
||||
sed -i 's/^user-session=.*/user-session=rpd-labwc/' /etc/lightdm/lightdm.conf
|
||||
sed -i 's/^autologin-session=.*/autologin-session=rpd-labwc/' /etc/lightdm/lightdm.conf
|
||||
log "Patched /etc/lightdm/lightdm.conf to use rpd-labwc"
|
||||
fi
|
||||
|
||||
# --- 5. Maliit on-screen keyboard (from file server) ---
|
||||
log "--- Maliit ---"
|
||||
mkdir -p "$AUTOSTART" "$PI_HOME/.config"
|
||||
curl -fsSL "${FILE_SERVER}/maliit-keyboard.desktop" -o "$AUTOSTART/maliit-keyboard.desktop" 2>/dev/null && log "maliit-keyboard.desktop installed" || log "WARNING: Could not download maliit-keyboard.desktop"
|
||||
|
||||
# --- 5b. Dark theme (GTK + prefer dark for apps) ---
|
||||
log "--- Dark theme ---"
|
||||
GTK_SETTINGS="$PI_HOME/.config/gtk-3.0/settings.ini"
|
||||
mkdir -p "$(dirname "$GTK_SETTINGS")"
|
||||
if [[ ! -f "$GTK_SETTINGS" ]]; then
|
||||
printf '%s\n' '[Settings]' 'gtk-application-prefer-dark-theme=1' 'gtk-theme-name=PiXnoir' > "$GTK_SETTINGS"
|
||||
else
|
||||
grep -q '^gtk-application-prefer-dark-theme=' "$GTK_SETTINGS" && sed -i 's/^gtk-application-prefer-dark-theme=.*/gtk-application-prefer-dark-theme=1/' "$GTK_SETTINGS" || echo 'gtk-application-prefer-dark-theme=1' >> "$GTK_SETTINGS"
|
||||
grep -q '^gtk-theme-name=' "$GTK_SETTINGS" && sed -i 's/^gtk-theme-name=.*/gtk-theme-name=PiXnoir/' "$GTK_SETTINGS" || echo 'gtk-theme-name=PiXnoir' >> "$GTK_SETTINGS"
|
||||
fi
|
||||
# Fallback if PiXnoir not installed (e.g. older image): Adwaita-dark
|
||||
log "Set dark theme (PiXnoir) in gtk-3.0/settings.ini"
|
||||
chown -R "$PI_USER:$PI_USER" "$PI_HOME/.config"
|
||||
|
||||
# --- 6. reTerminal DM drivers (Seeed) ---
|
||||
log "--- reTerminal DM drivers ---"
|
||||
REPO_DIR="/tmp/seeed-linux-dtoverlays"
|
||||
log "Cloning seeed-linux-dtoverlays to $REPO_DIR ..."
|
||||
git clone --depth 1 https://github.com/Seeed-Studio/seeed-linux-dtoverlays "$REPO_DIR"
|
||||
# Script must run from repo root (it uses pwd for MOD_PATH). On bookworm+ --compat-kernel is not supported.
|
||||
log "Running reTerminal.sh --device reTerminal-DM from $REPO_DIR ..."
|
||||
if ( cd "$REPO_DIR" && "$REPO_DIR/scripts/reTerminal.sh" --device reTerminal-DM ); then
|
||||
log "reTerminal DM drivers installed (reboot will apply)"
|
||||
else
|
||||
log "WARNING: reTerminal.sh failed (see log above). Display/touch may still work; you can retry later with: cd $REPO_DIR && sudo ./scripts/reTerminal.sh --device reTerminal-DM"
|
||||
fi
|
||||
log "Removing $REPO_DIR"
|
||||
rm -rf "$REPO_DIR"
|
||||
|
||||
# --- 6b. Re-apply splash and display (Seeed script sets disable_splash=1 and can duplicate Plymouth theme) ---
|
||||
log "--- Re-applying boot splash and Plymouth theme ---"
|
||||
CFG_PATH="/boot/firmware/config.txt"
|
||||
[[ -f /boot/firmware/config.txt ]] || CFG_PATH="/boot/config.txt"
|
||||
if [[ -f "$CFG_PATH" ]]; then
|
||||
if grep -q '^disable_splash=1' "$CFG_PATH"; then
|
||||
sed -i 's/^disable_splash=1$/disable_splash=0/' "$CFG_PATH"
|
||||
log "Set disable_splash=0 so Plymouth splash is shown"
|
||||
fi
|
||||
fi
|
||||
# Ensure Plymouth uses our custom theme only (single [Daemon], Theme=custom)
|
||||
if [[ -f /etc/plymouth/plymouthd.conf ]]; then
|
||||
sed -i '/^Theme=/d' /etc/plymouth/plymouthd.conf
|
||||
sed -i '/^\[Daemon\]$/d' /etc/plymouth/plymouthd.conf
|
||||
grep -q '^\[Daemon\]' /etc/plymouth/plymouthd.conf || echo '[Daemon]' >> /etc/plymouth/plymouthd.conf
|
||||
echo 'Theme=custom' >> /etc/plymouth/plymouthd.conf
|
||||
log "Plymouth theme set to custom only"
|
||||
fi
|
||||
log "Running update-initramfs to apply Plymouth theme ..."
|
||||
update-initramfs -u -k all 2>/dev/null || true
|
||||
|
||||
# --- 6b2. Kernel cmdline: swiotlb for vc4-drm (avoids "swiotlb buffer is full" / blank DSI on CM4) ---
|
||||
CMDLINE_PATH="/boot/firmware/cmdline.txt"
|
||||
[[ -f "$CMDLINE_PATH" ]] || CMDLINE_PATH="/boot/cmdline.txt"
|
||||
if [[ -f "$CMDLINE_PATH" ]] && ! grep -q 'swiotlb=' "$CMDLINE_PATH"; then
|
||||
sed -i 's/rootwait/rootwait swiotlb=65536/' "$CMDLINE_PATH"
|
||||
log "Added swiotlb=65536 to kernel cmdline (vc4-drm / DSI)"
|
||||
fi
|
||||
|
||||
# --- 6c. CM4: enable rpi-eeprom-update so boot order can be set ---
|
||||
# On CM4, rpi-eeprom-update is disabled by default. Enable it by setting flags in /etc/default/rpi-eeprom-update.
|
||||
# We use the bootloader method (pieeprom.upd file placed in /boot/firmware), NOT flashrom.
|
||||
# NOTE: Do NOT add dtoverlay=audremap or dtoverlay=spi-gpio40-45 to config.txt.
|
||||
# Those are only needed for the flashrom (direct SPI) method, and audremap CONFLICTS with
|
||||
# the reTerminal DM display backlight (both use GPIO13 PWM).
|
||||
# See: https://github.com/raspberrypi/usbboot , /etc/default/rpi-eeprom-update
|
||||
log "--- CM4 EEPROM update enable (for boot order) ---"
|
||||
EEPROM_DEFAULT="/etc/default/rpi-eeprom-update"
|
||||
if [[ ! -f "$EEPROM_DEFAULT" ]]; then
|
||||
touch "$EEPROM_DEFAULT"
|
||||
log "Created $EEPROM_DEFAULT"
|
||||
fi
|
||||
grep -q '^RPI_EEPROM_USE_FLASHROM=' "$EEPROM_DEFAULT" && sed -i 's/^RPI_EEPROM_USE_FLASHROM=.*/RPI_EEPROM_USE_FLASHROM=1/' "$EEPROM_DEFAULT" || echo 'RPI_EEPROM_USE_FLASHROM=1' >> "$EEPROM_DEFAULT"
|
||||
grep -q '^CM4_ENABLE_RPI_EEPROM_UPDATE=' "$EEPROM_DEFAULT" && sed -i 's/^CM4_ENABLE_RPI_EEPROM_UPDATE=.*/CM4_ENABLE_RPI_EEPROM_UPDATE=1/' "$EEPROM_DEFAULT" || echo 'CM4_ENABLE_RPI_EEPROM_UPDATE=1' >> "$EEPROM_DEFAULT"
|
||||
log "Set RPI_EEPROM_USE_FLASHROM=1 and CM4_ENABLE_RPI_EEPROM_UPDATE=1 in $EEPROM_DEFAULT"
|
||||
|
||||
# --- 6d. Boot order: network first, then eMMC/SD (for future network boot / re-provisioning) ---
|
||||
# BOOT_ORDER: 0x2 = network, 0x1 = SD/eMMC. 0x21 = try network first, then local storage.
|
||||
# On CM4, rpi-eeprom-update -l only works after reboot (once 6c is applied). So we try now; if it fails, a one-shot runs after next boot.
|
||||
log "--- Boot order (network first, then eMMC/SD) ---"
|
||||
BOOTCONF="/tmp/first-boot-eeprom-conf.txt"
|
||||
BOOT_ORDER_SET=0
|
||||
if command -v rpi-eeprom-config >/dev/null 2>&1 && command -v rpi-eeprom-update >/dev/null 2>&1; then
|
||||
if PEE="$(rpi-eeprom-update -l 2>/dev/null)" && [[ -n "$PEE" ]] && [[ -f "$PEE" ]]; then
|
||||
rpi-eeprom-config "$PEE" > "$BOOTCONF" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -s "$BOOTCONF" ]]; then
|
||||
sed -i 's/^BOOT_ORDER=.*/BOOT_ORDER=0x21/' "$BOOTCONF"
|
||||
grep -q '^BOOT_ORDER=' "$BOOTCONF" || echo 'BOOT_ORDER=0x21' >> "$BOOTCONF"
|
||||
if rpi-eeprom-config --apply "$BOOTCONF" 2>/dev/null; then
|
||||
log "Boot order set to 0x21 (network first, then eMMC/SD); EEPROM update scheduled for next reboot"
|
||||
BOOT_ORDER_SET=1
|
||||
else
|
||||
log "WARNING: rpi-eeprom-config --apply failed; boot order unchanged"
|
||||
fi
|
||||
else
|
||||
log "rpi-eeprom-update -l did not return a config (on CM4 this is normal until after reboot with 6c applied); scheduling one-shot to set boot order after next boot"
|
||||
fi
|
||||
rm -f "$BOOTCONF"
|
||||
else
|
||||
log "rpi-eeprom-config/rpi-eeprom-update not found; skipping boot order (not a Pi4/CM4 or package missing)"
|
||||
fi
|
||||
|
||||
# If boot order was not set (e.g. CM4 first boot), install a one-shot systemd service to set it after reboot
|
||||
if [[ "$BOOT_ORDER_SET" -eq 0 ]] && command -v rpi-eeprom-config >/dev/null 2>&1 && command -v rpi-eeprom-update >/dev/null 2>&1; then
|
||||
ONCE_SCRIPT="/usr/local/bin/set-cm4-boot-order-once.sh"
|
||||
ONCE_SVC="/etc/systemd/system/set-cm4-boot-order-once.service"
|
||||
cat > "$ONCE_SCRIPT" << 'SETBOOTEOF'
|
||||
#!/bin/bash
|
||||
# One-shot: set BOOT_ORDER=0x21 (network first) when rpi-eeprom-update becomes available (e.g. after CM4 enable and reboot).
|
||||
BOOTCONF="/tmp/eeprom-boot-order-once.txt"
|
||||
if PEE="$(rpi-eeprom-update -l 2>/dev/null)" && [[ -n "$PEE" ]] && [[ -f "$PEE" ]]; then
|
||||
rpi-eeprom-config "$PEE" > "$BOOTCONF" 2>/dev/null
|
||||
if [[ -s "$BOOTCONF" ]]; then
|
||||
sed -i 's/^BOOT_ORDER=.*/BOOT_ORDER=0x21/' "$BOOTCONF"
|
||||
grep -q '^BOOT_ORDER=' "$BOOTCONF" || echo 'BOOT_ORDER=0x21' >> "$BOOTCONF"
|
||||
if rpi-eeprom-config --apply "$BOOTCONF" 2>/dev/null; then
|
||||
echo "Boot order set to 0x21 (network first, then eMMC/SD)"
|
||||
fi
|
||||
fi
|
||||
rm -f "$BOOTCONF"
|
||||
fi
|
||||
systemctl disable set-cm4-boot-order-once.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/set-cm4-boot-order-once.service
|
||||
rm -f "$0"
|
||||
SETBOOTEOF
|
||||
chmod 755 "$ONCE_SCRIPT"
|
||||
cat > "$ONCE_SVC" << 'SVCEOF'
|
||||
[Unit]
|
||||
Description=Set CM4 boot order once (network first)
|
||||
After=multi-user.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/set-cm4-boot-order-once.sh
|
||||
RemainAfterExit=no
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
systemctl enable set-cm4-boot-order-once.service 2>/dev/null && log "Enabled set-cm4-boot-order-once.service to set boot order after next boot"
|
||||
fi
|
||||
|
||||
# --- 7. One-shots (rotation at first login; wallpaper already set in pcmanfm config above) ---
|
||||
log "--- One-shot scripts (run at pi first login) ---"
|
||||
install_oneshot set-rotation-once || true
|
||||
log "One-shots will append to $LOGFILE when they run at first login"
|
||||
|
||||
# --- 8. Allow pi to append to first-boot.log (for one-shot scripts) ---
|
||||
chmod 666 "$LOGFILE"
|
||||
log "Log file $LOGFILE is now appendable by user $PI_USER for one-shot scripts"
|
||||
|
||||
# --- 9. Reboot ---
|
||||
log "=== first-boot.sh finished, rebooting ==="
|
||||
reboot
|
||||
36
emmc-provisioning/cloud-init/fix-reterminal-display.sh
Normal file
36
emmc-provisioning/cloud-init/fix-reterminal-display.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# One-time fix for reTerminal DM after first-boot: splash, Plymouth theme, rotation, wallpaper.
|
||||
# Run on the device as root (e.g. sudo bash fix-reterminal-display.sh).
|
||||
# Or run over SSH: ssh pi@DEVICE_IP 'sudo bash -s' < fix-reterminal-display.sh
|
||||
|
||||
set -e
|
||||
PI_USER="${PI_USER:-pi}"
|
||||
PI_HOME="/home/$PI_USER"
|
||||
CFG_PATH="/boot/firmware/config.txt"
|
||||
[[ -f "$CFG_PATH" ]] || CFG_PATH="/boot/config.txt"
|
||||
|
||||
echo "=== Fixing boot splash (disable_splash=0) ==="
|
||||
if [[ -f "$CFG_PATH" ]]; then
|
||||
sed -i 's/^disable_splash=1$/disable_splash=0/' "$CFG_PATH" || true
|
||||
grep -q '^disable_splash=' "$CFG_PATH" || echo 'disable_splash=0' >> "$CFG_PATH"
|
||||
echo "Done. config: $(grep disable_splash "$CFG_PATH")"
|
||||
fi
|
||||
|
||||
echo "=== Fixing Plymouth theme (custom only, no duplicate [Daemon]) ==="
|
||||
if [[ -f /etc/plymouth/plymouthd.conf ]]; then
|
||||
sed -i '/^Theme=/d' /etc/plymouth/plymouthd.conf
|
||||
sed -i '/^\[Daemon\]$/d' /etc/plymouth/plymouthd.conf
|
||||
grep -q '^\[Daemon\]' /etc/plymouth/plymouthd.conf || echo '[Daemon]' >> /etc/plymouth/plymouthd.conf
|
||||
echo 'Theme=custom' >> /etc/plymouth/plymouthd.conf
|
||||
echo "Done. plymouthd.conf Theme: $(grep Theme= /etc/plymouth/plymouthd.conf)"
|
||||
fi
|
||||
update-initramfs -u -k all 2>/dev/null || true
|
||||
|
||||
echo "=== Rotation and wallpaper (rpd-labwc / labwc Wayland) ==="
|
||||
echo "To set rotation and wallpaper now (in a labwc session), run as $PI_USER:"
|
||||
echo " wlr-randr --output \$(wlr-randr | awk '/^[A-Za-z0-9_-]+ /{print \$1; exit}') --transform 270"
|
||||
echo " pcmanfm --set-wallpaper /usr/share/rpd-wallpaper/splash.png --wallpaper-mode=crop"
|
||||
echo ""
|
||||
echo "Or ensure one-shots run at next login (they are in autostart if still present)."
|
||||
echo "=== Reboot to apply splash and initramfs ==="
|
||||
echo " sudo reboot"
|
||||
5
emmc-provisioning/cloud-init/meta-data
Normal file
5
emmc-provisioning/cloud-init/meta-data
Normal file
@@ -0,0 +1,5 @@
|
||||
# NoCloud meta-data: enables cloud-init. Optional instance-id for multi-device.
|
||||
# Copy to the boot (FAT32) partition of your image as 'meta-data'.
|
||||
|
||||
instance-id: gnss-guard-01
|
||||
# local-hostname: gnss.guard # optional override; first-boot.sh also sets this
|
||||
21
emmc-provisioning/cloud-init/network-config
Normal file
21
emmc-provisioning/cloud-init/network-config
Normal file
@@ -0,0 +1,21 @@
|
||||
# Cloud-init network-config (NoCloud). Copy to boot partition as 'network-config'.
|
||||
# Adjust for your LAN: DHCP or static.
|
||||
|
||||
version: 2
|
||||
ethernets:
|
||||
eth0:
|
||||
dhcp4: true
|
||||
# eth0:
|
||||
# addresses:
|
||||
# - 192.168.1.100/24
|
||||
# gateway4: 192.168.1.1
|
||||
# nameservers:
|
||||
# addresses:
|
||||
# - 8.8.8.8
|
||||
|
||||
# Optional WiFi (uncomment and set your SSID/password)
|
||||
# wlan0:
|
||||
# dhcp4: true
|
||||
# access-points:
|
||||
# "YourSSID":
|
||||
# password: "YourPassword"
|
||||
30
emmc-provisioning/cloud-init/set-rotation-once.sh
Normal file
30
emmc-provisioning/cloud-init/set-rotation-once.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# One-shot: set reTerminal DM (labwc/Wayland) rotation to Left via wlr-randr, then remove self.
|
||||
# Runs once as user pi at first login; deletes its autostart and this script so it never runs again.
|
||||
# Logs to /var/log/first-boot.log.
|
||||
FIRST_BOOT_LOG="/var/log/first-boot.log"
|
||||
log() { echo "[$(date -Iseconds)] [set-rotation-once] $*" >> "$FIRST_BOOT_LOG" 2>/dev/null || true; }
|
||||
|
||||
log "started (labwc/wlr-randr)"
|
||||
log "waiting 5s for compositor ..."
|
||||
sleep 5
|
||||
|
||||
OUTPUT=""
|
||||
if command -v wlr-randr &>/dev/null; then
|
||||
OUTPUT=$(wlr-randr 2>/dev/null | awk '/^[A-Za-z0-9_-]+ /{print $1; exit}')
|
||||
fi
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
OUTPUT="DSI-1"
|
||||
log "using default output: $OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ -n "$OUTPUT" ]] && command -v wlr-randr &>/dev/null; then
|
||||
log "applying rotation left (transform 270) on $OUTPUT"
|
||||
wlr-randr --output "$OUTPUT" --transform 270 2>&1 | while read -r line; do log "$line"; done
|
||||
else
|
||||
log "WARNING: wlr-randr not found or no output"
|
||||
fi
|
||||
|
||||
log "removing one-shot desktop and script"
|
||||
rm -f /home/pi/.config/autostart/set-rotation-once.desktop /home/pi/set-rotation-once.sh
|
||||
log "finished"
|
||||
36
emmc-provisioning/cloud-init/set-wallpaper-once.sh
Normal file
36
emmc-provisioning/cloud-init/set-wallpaper-once.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# One-shot: set desktop wallpaper for labwc (swaybg) and persist via labwc autostart, then remove self.
|
||||
# Runs as user pi at first login. Logs to /var/log/first-boot.log.
|
||||
FIRST_BOOT_LOG="/var/log/first-boot.log"
|
||||
log() { echo "[$(date -Iseconds)] [set-wallpaper-once] $*" >> "$FIRST_BOOT_LOG" 2>/dev/null || true; }
|
||||
|
||||
WALLPAPER="/usr/share/rpd-wallpaper/splash.png"
|
||||
LABWC_AUTOSTART="$HOME/.config/labwc/autostart"
|
||||
|
||||
log "started (labwc/swaybg)"
|
||||
log "waiting 8s for compositor ..."
|
||||
sleep 8
|
||||
|
||||
if [[ ! -f "$WALLPAPER" ]]; then
|
||||
log "WARNING: wallpaper not found $WALLPAPER"
|
||||
else
|
||||
mkdir -p "$(dirname "$LABWC_AUTOSTART")"
|
||||
if [[ ! -f "$LABWC_AUTOSTART" ]]; then
|
||||
echo '#!/bin/sh' > "$LABWC_AUTOSTART"
|
||||
chmod +x "$LABWC_AUTOSTART"
|
||||
fi
|
||||
if ! grep -q 'swaybg.*splash.png' "$LABWC_AUTOSTART" 2>/dev/null; then
|
||||
echo "swaybg -i $WALLPAPER -m fill &" >> "$LABWC_AUTOSTART"
|
||||
log "added swaybg to labwc autostart"
|
||||
fi
|
||||
if command -v swaybg &>/dev/null; then
|
||||
log "applying wallpaper now: $WALLPAPER"
|
||||
swaybg -i "$WALLPAPER" -m fill &
|
||||
else
|
||||
log "WARNING: swaybg not installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "removing one-shot desktop and script"
|
||||
rm -f /home/pi/.config/autostart/set-wallpaper-once.desktop /home/pi/set-wallpaper-once.sh
|
||||
log "finished"
|
||||
48
emmc-provisioning/cloud-init/start-chromium.sh
Executable file
48
emmc-provisioning/cloud-init/start-chromium.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Disable keyring prompts
|
||||
export GNOME_KEYRING_CONTROL=""
|
||||
export DISPLAY=:0
|
||||
|
||||
# Force X11 instead of Wayland for better fullscreen support
|
||||
export GDK_BACKEND=x11
|
||||
unset WAYLAND_DISPLAY
|
||||
|
||||
# Wait for display and desktop environment to be ready
|
||||
# Check if DISPLAY is accessible (wait up to 30 seconds)
|
||||
for i in {1..60}; do
|
||||
if xset q >/dev/null 2>&1 || [ -n "$DISPLAY" ]; then
|
||||
# Wait for desktop environment to be fully loaded
|
||||
if pgrep -x pcmanfm >/dev/null 2>&1 || pgrep -x lxsession >/dev/null 2>&1 || pgrep -x xfdesktop >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Additional delay to ensure window manager is fully ready
|
||||
sleep 5
|
||||
|
||||
# Start Chromium with flags to avoid keyring and ensure proper fullscreen
|
||||
# Force X11 platform and add fullscreen-related flags
|
||||
# Fullscreen mode (current active)
|
||||
/usr/bin/chromium --start-fullscreen --noerrdialogs --disable-infobars --disable-session-crashed-bubble --disable-restore-session-state --no-first-run --password-store=basic --use-mock-keychain --ozone-platform=x11 --disable-features=UseChromeOSDirectVideoDecoder --app=http://127.0.0.1:8080 &
|
||||
|
||||
# Wait for Chromium window to appear and then force fullscreen
|
||||
sleep 3
|
||||
# Try to find Chromium window and force it to fullscreen
|
||||
for i in {1..10}; do
|
||||
WINDOW_ID=$(wmctrl -l 2>/dev/null | grep -i chromium | head -1 | awk '{print $1}')
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
wmctrl -i -r "$WINDOW_ID" -b add,fullscreen 2>/dev/null
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
|
||||
|
||||
# Keep script running
|
||||
wait
|
||||
|
||||
# Kiosk mode (commented out - uncomment to use instead of fullscreen)
|
||||
# /usr/bin/chromium --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --disable-restore-session-state --no-first-run --password-store=basic --use-mock-keychain --ozone-platform=x11 --app=http://127.0.0.1:8080
|
||||
34
emmc-provisioning/cloud-init/user-data
Normal file
34
emmc-provisioning/cloud-init/user-data
Normal file
@@ -0,0 +1,34 @@
|
||||
#cloud-config
|
||||
# Cloud-init user-data for reTerminal DM4 golden image (eMMC).
|
||||
# Copy to the boot (FAT32) partition of your image as 'user-data'.
|
||||
# Raspberry Pi OS uses NoCloud: meta-data, user-data, network-config on boot partition.
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
|
||||
packages:
|
||||
- chromium-browser
|
||||
- wmctrl
|
||||
# - python3-pip # uncomment if you need Flask/other apps
|
||||
|
||||
# Optional: set hostname from serial or leave default
|
||||
# hostname: reterminal-%s # %s = first column of meta-data instance-id if set
|
||||
|
||||
# Optional: enable I2C/SPI for reTerminal peripherals (LED, buzzer, etc.)
|
||||
# Uncomment if your image does not already enable these:
|
||||
# write_files:
|
||||
# - path: /boot/firmware/config.txt.d/99-reterminal.txt
|
||||
# content: |
|
||||
# dtparam=i2c_arm=on
|
||||
# dtparam=spi=on
|
||||
|
||||
# Run once on first boot (e.g. copy kiosk scripts, start Chromium on boot)
|
||||
runcmd:
|
||||
# Example: ensure Chromium kiosk autostart
|
||||
# - systemctl enable chromium-kiosk
|
||||
- cloud-init single --name cc_final_message
|
||||
|
||||
# Power state after first boot (optional)
|
||||
# power_state:
|
||||
# mode: reboot
|
||||
# delay: 1
|
||||
@@ -0,0 +1,118 @@
|
||||
#cloud-config
|
||||
# Example: create user (pi) with password, enable SSH, install KDE Plasma with touch options,
|
||||
# set KDE as default GUI, and deploy Chromium kiosk autostart.
|
||||
# Uses start-chromium.sh and chromium-kiosk.desktop from this project (emmc-provisioning/cloud-init/).
|
||||
#
|
||||
# 1. Generate a password hash on a Linux host:
|
||||
# mkpasswd -m sha-512 'YourPassword'
|
||||
# or: openssl passwd -6 'YourPassword'
|
||||
# Paste the full output (e.g. $6$...) into the passwd: line below.
|
||||
# 2. To use a different username than "pi", replace every "pi" in this file.
|
||||
# 3. To change the kiosk URL, edit the --app=... line in the start-chromium.sh content below.
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
|
||||
packages:
|
||||
- chromium-browser
|
||||
- wmctrl
|
||||
- openssh-server
|
||||
# KDE Plasma + touchscreen
|
||||
- kde-plasma-desktop
|
||||
- maliit-keyboard
|
||||
- xinput-calibrator
|
||||
|
||||
# Create user and set password (use hash from mkpasswd -m sha-512 or openssl passwd -6)
|
||||
users:
|
||||
- name: pi
|
||||
groups: [adm, sudo, video]
|
||||
lock_passwd: false
|
||||
passwd: "$6$7xWGhGc6d1lJx1dU$4E8r1mkzVj51bjEbfzdP8wPxso..C36LbXkqU/X4oBGq94aGFMSrZb0PVI8zs/Om1Jm97/D..Apy2HTdCn3FV1"
|
||||
shell: /bin/bash
|
||||
|
||||
# Enable SSH (allow password auth so you can log in with the user above)
|
||||
write_files:
|
||||
- path: /etc/ssh/sshd_config.d/99-cloud-init.conf
|
||||
content: |
|
||||
PasswordAuthentication yes
|
||||
PermitRootLogin no
|
||||
- path: /home/pi/start-chromium.sh
|
||||
content: |
|
||||
#!/bin/bash
|
||||
export GNOME_KEYRING_CONTROL=""
|
||||
export DISPLAY=:0
|
||||
export GDK_BACKEND=x11
|
||||
unset WAYLAND_DISPLAY
|
||||
for i in {1..60}; do
|
||||
if xset q >/dev/null 2>&1 || [ -n "$DISPLAY" ]; then
|
||||
if pgrep -x plasma_session >/dev/null 2>&1 || pgrep -x kwin_x11 >/dev/null 2>&1 || pgrep -x pcmanfm >/dev/null 2>&1 || pgrep -x lxsession >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
sleep 5
|
||||
/usr/bin/chromium --start-fullscreen --noerrdialogs --disable-infobars --disable-session-crashed-bubble --disable-restore-session-state --no-first-run --password-store=basic --use-mock-keychain --ozone-platform=x11 --disable-features=UseChromeOSDirectVideoDecoder --app=http://127.0.0.1:8080 &
|
||||
sleep 3
|
||||
for i in {1..10}; do
|
||||
WINDOW_ID=$(wmctrl -l 2>/dev/null | grep -i chromium | head -1 | awk '{print $1}')
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
wmctrl -i -r "$WINDOW_ID" -b add,fullscreen 2>/dev/null
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
wait
|
||||
owner: pi:pi
|
||||
permissions: "0755"
|
||||
- path: /home/pi/.config/autostart/chromium-kiosk.desktop
|
||||
content: |
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Chromium Fullscreen
|
||||
Exec=/home/pi/start-chromium.sh
|
||||
Hidden=false
|
||||
NoDisplay=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
owner: pi:pi
|
||||
permissions: "0644"
|
||||
# KDE Plasma: switch to KDE as default session (X11 for Chromium compatibility)
|
||||
- path: /etc/lightdm/lightdm.conf.d/99-default-session.conf
|
||||
content: |
|
||||
[Seat:*]
|
||||
user-session=plasmax11
|
||||
permissions: "0644"
|
||||
# KDE touch-friendly: UI scale and input (for pi after first login)
|
||||
- path: /home/pi/.config/kdeglobals
|
||||
content: |
|
||||
[General]
|
||||
ForceFontDPI=120
|
||||
owner: pi:pi
|
||||
permissions: "0644"
|
||||
- path: /home/pi/.config/kwinrc
|
||||
content: |
|
||||
[Windows]
|
||||
BorderlessMaximizedWindows=true
|
||||
[Plugins]
|
||||
touchpointsEnabled=true
|
||||
owner: pi:pi
|
||||
permissions: "0644"
|
||||
# Start on-screen keyboard (maliit) with KDE for touch input
|
||||
- path: /home/pi/.config/autostart/maliit-keyboard.desktop
|
||||
content: |
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Maliit Keyboard
|
||||
Exec=maliit-keyboard -r
|
||||
X-GNOME-Autostart-enabled=true
|
||||
owner: pi:pi
|
||||
permissions: "0644"
|
||||
|
||||
runcmd:
|
||||
- mkdir -p /home/pi/.config/autostart
|
||||
- chown -R pi:pi /home/pi/.config
|
||||
# Set KDE Plasma (X11) as default session so next boot uses KDE
|
||||
- update-alternatives --set x-session-manager /usr/bin/startplasma-x11 2>/dev/null || true
|
||||
- systemctl enable ssh
|
||||
- systemctl start ssh
|
||||
- cloud-init single --name cc_final_message
|
||||
@@ -0,0 +1,21 @@
|
||||
#cloud-config
|
||||
# Example user-data that downloads and runs a script from a file server.
|
||||
# Use this so you can change first-boot behaviour without rebuilding the golden image.
|
||||
# Copy to boot partition as 'user-data' and set BOOTSTRAP_URL to your script URL.
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
|
||||
packages:
|
||||
- curl # or wget if you prefer
|
||||
|
||||
# Download script from file server and run it (runcmd runs after network is up)
|
||||
runcmd:
|
||||
- curl -fsSL "http://YOUR_FILE_SERVER/provisioning/bootstrap.sh" -o /tmp/bootstrap.sh
|
||||
- chmod +x /tmp/bootstrap.sh
|
||||
- /tmp/bootstrap.sh
|
||||
# Optional: remove after run
|
||||
# - rm -f /tmp/bootstrap.sh
|
||||
|
||||
# Optional: finish cloud-init
|
||||
# - cloud-init single --name cc_final_message
|
||||
37
emmc-provisioning/cloud-init/user-data-remote-gnss.example
Normal file
37
emmc-provisioning/cloud-init/user-data-remote-gnss.example
Normal file
@@ -0,0 +1,37 @@
|
||||
#cloud-config
|
||||
# Example: create user (pi), enable SSH, then download and run first-boot.sh to install
|
||||
# Chromium kiosk, rpd-labwc, and touch options. Edit FIRST_BOOT_URL to point to your
|
||||
# hosted first-boot.sh (e.g. file server or raw GitHub).
|
||||
#
|
||||
# 1. Generate a password hash: mkpasswd -m sha-512 'YourPassword' or openssl passwd -6 'YourPassword'
|
||||
# Paste the full output into the passwd: line below.
|
||||
# 2. Host first-boot.sh (same dir as this repo: cloud-init/first-boot.sh) at FIRST_BOOT_URL.
|
||||
# 3. To use a different username than "pi", replace every "pi" in this file and in first-boot.sh.
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
|
||||
packages:
|
||||
- curl
|
||||
|
||||
users:
|
||||
- name: pi
|
||||
groups: [adm, sudo, video]
|
||||
lock_passwd: false
|
||||
passwd: "$6$7xWGhGc6d1lJx1dU$4E8r1mkzVj51bjEbfzdP8wPxso..C36LbXkqU/X4oBGq94aGFMSrZb0PVI8zs/Om1Jm97/D..Apy2HTdCn3FV1"
|
||||
shell: /bin/bash
|
||||
|
||||
write_files:
|
||||
- path: /etc/ssh/sshd_config.d/99-cloud-init.conf
|
||||
content: |
|
||||
PasswordAuthentication yes
|
||||
PermitRootLogin no
|
||||
|
||||
runcmd:
|
||||
- systemctl enable ssh
|
||||
- systemctl start ssh
|
||||
- curl -fsSL "http://10.130.60.141:5000/files/first-boot.sh" -o /tmp/first-boot.sh
|
||||
- chmod +x /tmp/first-boot.sh
|
||||
- /tmp/first-boot.sh
|
||||
# - rm -f /tmp/first-boot.sh
|
||||
- cloud-init single --name cc_final_message
|
||||
53
emmc-provisioning/dashboard/README.md
Normal file
53
emmc-provisioning/dashboard/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# CM4 Provisioning Dashboard
|
||||
|
||||
Flask web UI for CM4 eMMC provisioning: **public home** (deploy only) and **admin** (login required) for images, cloud-init, portal files, and users.
|
||||
|
||||
## Public home (`/`)
|
||||
|
||||
- **No login.** Anyone can:
|
||||
- See current status (idle / connecting / flashing / backup / done / error).
|
||||
- **Deploy** or **Backup** when a device is connected (USB boot or network).
|
||||
- See which image is set as **golden** (used for Deploy).
|
||||
- View recent log and “How to connect” steps.
|
||||
|
||||
Layout: compact two columns (status + deploy on the left; golden info, log, and connect on the right).
|
||||
|
||||
## Admin (`/admin`)
|
||||
|
||||
**Login required.** First user: open `/login`, enter any username and a password (min 6 characters) to create the first admin account.
|
||||
|
||||
Admin can:
|
||||
|
||||
- **Backup images** (in `backups/`): upload, rename, delete, shrink/compress, **set as golden**, download.
|
||||
- **Cloud-init images** (in `cloudinit-images/`): list, set as golden, rename, delete, download.
|
||||
- **Build cloud-init image**: download latest Raspberry Pi OS (arm64), inject cloud-init, edit user-data/meta-data/network-config, use templates. Output goes to cloud-init images; optionally set as golden after build.
|
||||
- **Portal files** (in `portal-files/`): upload files that are served at `/files/<name>` so cloud-init can `wget` or `curl` them on first boot (e.g. `curl -fsSL "http://SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh`).
|
||||
- **Golden image**: any backup or cloud-init image can be set as golden (no single “golden file”; choose from either list).
|
||||
- **Admin users**: add users, change password.
|
||||
- **Activity logs**: view recent admin actions.
|
||||
|
||||
Backup images and cloud-init images live in **separate folders** (`backups/` and `cloudinit-images/`).
|
||||
|
||||
## Run locally (development)
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
pip install -r requirements.txt # Flask, werkzeug
|
||||
python3 app.py
|
||||
# Open http://localhost:5000 (home), http://localhost:5000/admin (login first)
|
||||
```
|
||||
|
||||
## Run in LXC (Proxmox)
|
||||
|
||||
Deploy with `scripts/deploy-to-proxmox.sh`; it installs the dashboard and pushes `home.html`, `login.html`, `admin.html`. Optional: create `/opt/cm4-provisioning/dashboard.env` with `CM4_DASHBOARD_SECRET_KEY=<random>` so session logins persist across restarts (deploy script can create this automatically).
|
||||
|
||||
## Environment (optional)
|
||||
|
||||
- `CM4_PROVISIONING_DIR` – base path (default: `/var/lib/cm4-provisioning`).
|
||||
- `CM4_BACKUPS_DIR` – backups directory (default: `…/backups`).
|
||||
- `CM4_CLOUDINIT_IMAGES_DIR` – cloud-init built images (default: `…/cloudinit-images`).
|
||||
- `CM4_PORTAL_FILES_DIR` – files served at `/files/` (default: `…/portal-files`).
|
||||
- `CM4_GOLDEN_IMAGE` – path to golden image (symlink to chosen backup or cloud-init image).
|
||||
- `CM4_DASHBOARD_SECRET_KEY` – secret for session cookies (set in production).
|
||||
- `CM4_DASHBOARD_DB` – SQLite path for admin users and logs (default: `…/dashboard.db`).
|
||||
- `CM4_STATUS_FILE`, `CM4_LOG_FILE`, etc. – as before for status and build.
|
||||
1353
emmc-provisioning/dashboard/app.py
Normal file
1353
emmc-provisioning/dashboard/app.py
Normal file
File diff suppressed because it is too large
Load Diff
19
emmc-provisioning/dashboard/cm4-dashboard.service
Normal file
19
emmc-provisioning/dashboard/cm4-dashboard.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=CM4 eMMC Provisioning Dashboard
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/cm4-provisioning/dashboard
|
||||
ExecStart=/usr/bin/python3 -m flask --app app run --host=0.0.0.0 --port=5000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=FLASK_ENV=production
|
||||
# Data dir (portal-files, backups, etc.); override in dashboard.env if needed
|
||||
Environment=CM4_PROVISIONING_DIR=/var/lib/cm4-provisioning
|
||||
EnvironmentFile=-/opt/cm4-provisioning/dashboard.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
2
emmc-provisioning/dashboard/requirements.txt
Normal file
2
emmc-provisioning/dashboard/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask>=2.0
|
||||
werkzeug>=2.0
|
||||
253
emmc-provisioning/dashboard/templates/admin.html
Normal file
253
emmc-provisioning/dashboard/templates/admin.html
Normal file
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin · CM4 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">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
||||
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
||||
--border: #2d333b; --danger: #f87171; --warn: #fbbf24; --success: #00d4aa; --radius: 10px; --radius-sm: 6px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
||||
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
||||
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
||||
.header a:hover { color: var(--accent); }
|
||||
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
||||
.nav a:hover { color: var(--accent); }
|
||||
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
||||
.btn-primary:hover { background: var(--accent-dim); }
|
||||
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
||||
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
|
||||
.btn-danger:hover { background: rgba(248,113,113,0.3); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
||||
.empty-msg { text-align: center; padding: 1rem; font-size: 0.9rem; color: var(--text-muted); }
|
||||
details { margin-top: 0.5rem; }
|
||||
summary { cursor: pointer; font-weight: 500; color: var(--text-dim); padding: 0.4rem 0; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
|
||||
details[open] summary::before { transform: rotate(90deg); }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.actions-cell .btn { margin-right: 0.25rem; margin-bottom: 0.2rem; }
|
||||
input[type="text"], input[type="password"], textarea, select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
||||
textarea { width: 100%; min-height: 80px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
|
||||
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
|
||||
.portal-url { font-size: 0.8rem; color: var(--text-muted); word-break: break-all; margin-top: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>Admin</h1>
|
||||
<nav class="nav" style="margin-top:0.35rem;">
|
||||
<a href="/admin" class="active">Admin</a>
|
||||
<a href="/admin/portal-files">Portal files</a>
|
||||
<a href="/admin/cloudinit-build">Cloud-init build</a>
|
||||
</nav>
|
||||
</div>
|
||||
<span>Logged in as <strong>{{ username }}</strong></span>
|
||||
<a href="/">Deploy</a>
|
||||
<a href="/logout">Log out</a>
|
||||
</header>
|
||||
|
||||
<!-- Golden image -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Current deploy image (golden)</h2>
|
||||
<p id="goldenInfo" class="mono" style="font-size:0.9rem;">Loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- Backups -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">
|
||||
Backup images
|
||||
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn">Refresh</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="uploadBackupBtn">Upload backup</button>
|
||||
<input type="file" id="uploadBackupInput" accept=".img,.img.gz,.img.xz" style="display:none;" />
|
||||
</h2>
|
||||
<p id="backupsDirHint" class="mono" style="font-size:0.75rem; margin-bottom:0.5rem; color:var(--text-muted);"></p>
|
||||
<table id="backupsTable">
|
||||
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
|
||||
<tbody id="backupsBody"></tbody>
|
||||
</table>
|
||||
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups. Capture from device on home page or upload here.</p>
|
||||
</div>
|
||||
|
||||
<!-- Cloud-init images -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Cloud-init images <button type="button" class="btn btn-outline btn-sm" id="refreshCloudinitBtn">Refresh</button></h2>
|
||||
<table id="cloudinitTable">
|
||||
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
|
||||
<tbody id="cloudinitBody"></tbody>
|
||||
</table>
|
||||
<p id="cloudinitEmpty" class="empty-msg" style="display:none;">No cloud-init images. Build one below.</p>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Admin users <button type="button" class="btn btn-outline btn-sm" id="addUserBtn">Add user</button></h2>
|
||||
<div id="addUserForm" style="display:none; margin-bottom:0.75rem; padding:0.75rem; background:var(--bg-tertiary); border-radius:var(--radius-sm);">
|
||||
<input type="text" id="newUsername" placeholder="Username" />
|
||||
<input type="password" id="newPassword" placeholder="Password (min 6)" style="margin-left:0.25rem;" />
|
||||
<button type="button" id="addUserSubmit" class="btn btn-primary btn-sm" style="margin-left:0.25rem;">Create</button>
|
||||
<button type="button" id="addUserCancel" class="btn btn-outline btn-sm">Cancel</button>
|
||||
</div>
|
||||
<table id="usersTable"><thead><tr><th>Username</th><th>Created</th><th>Actions</th></tr></thead><tbody id="usersBody"></tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Activity logs -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Activity logs <button type="button" class="btn btn-outline btn-sm" id="refreshLogsBtn">Refresh</button></h2>
|
||||
<div id="adminLogs" class="mono" style="font-size:0.8rem; max-height:200px; overflow:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function authFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
return fetch(url, opts).then(function(r) {
|
||||
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
||||
return r;
|
||||
});
|
||||
}
|
||||
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
|
||||
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
|
||||
|
||||
function fetchGolden() {
|
||||
authFetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
|
||||
var el = document.getElementById('goldenInfo');
|
||||
if (!d.present) { el.textContent = 'No golden image set. Set one from Backup images or Cloud-init images above.'; return; }
|
||||
el.innerHTML = fmtSize(d.size) + ', updated ' + fmtDate(d.mtime) + (d.name ? ' — <span class="golden-badge">' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ': ' + escapeHtml(d.name) + '</span>' : '');
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
function renderBackups(backups, backupsDir) {
|
||||
var tbody = document.getElementById('backupsBody');
|
||||
var empty = document.getElementById('backupsEmpty');
|
||||
var dirHint = document.getElementById('backupsDirHint');
|
||||
if (backupsDir) dirHint.textContent = 'Dir: ' + backupsDir;
|
||||
tbody.innerHTML = '';
|
||||
if (!backups || backups.length === 0) { empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
backups.forEach(function(b){
|
||||
var isRaw = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
|
||||
var shrink = isRaw ? '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> <button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="'+escapeHtml(b.name)+'">Shrink</button> <button type="button" class="btn btn-outline btn-sm compress-btn" data-name="'+escapeHtml(b.name)+'">Compress</button> ' : '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> ';
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell">'+shrink+'<a href="/api/backups/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-backup" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-backup" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll('.set-golden-backup').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/backups/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
|
||||
});
|
||||
tbody.querySelectorAll('.shrink-btn').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Shrink (PiShrink)? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/shrink', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
|
||||
});
|
||||
tbody.querySelectorAll('.compress-btn').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Compress to .img.xz? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/compress', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({format:'xz'}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
|
||||
});
|
||||
tbody.querySelectorAll('.rename-backup').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }); };
|
||||
});
|
||||
tbody.querySelectorAll('.delete-backup').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }); };
|
||||
});
|
||||
}
|
||||
function fetchBackups() {
|
||||
authFetch('/api/backups').then(function(r){ return r.json(); }).then(function(d){ renderBackups(d.backups || [], d.backups_dir); }).catch(function(){});
|
||||
}
|
||||
|
||||
function renderCloudinit(images) {
|
||||
var tbody = document.getElementById('cloudinitBody');
|
||||
var empty = document.getElementById('cloudinitEmpty');
|
||||
tbody.innerHTML = '';
|
||||
if (!images || images.length === 0) { empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
images.forEach(function(b){
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell"><button type="button" class="btn btn-outline btn-sm set-golden-cloudinit" data-name="'+escapeHtml(b.name)+'">Set golden</button> <a href="/api/cloudinit-images/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-cloudinit" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-cloudinit" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll('.set-golden-cloudinit').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
|
||||
});
|
||||
tbody.querySelectorAll('.rename-cloudinit').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchCloudinit(); else alert(d.error); }); };
|
||||
});
|
||||
tbody.querySelectorAll('.delete-cloudinit').forEach(function(btn){
|
||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }); };
|
||||
});
|
||||
}
|
||||
function fetchCloudinit() {
|
||||
authFetch('/api/cloudinit-images').then(function(r){ return r.json(); }).then(function(d){ renderCloudinit(d.images || []); }).catch(function(){});
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
var tbody = document.getElementById('usersBody');
|
||||
tbody.innerHTML = '';
|
||||
(users || []).forEach(function(u){
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td>'+escapeHtml(u.username)+'</td><td class="mono">'+fmtDate(u.created_at)+'</td><td><button type="button" class="btn btn-outline btn-sm chpwd-btn" data-id="'+u.id+'" data-username="'+escapeHtml(u.username)+'">Change password</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll('.chpwd-btn').forEach(function(btn){
|
||||
btn.onclick = function(){ var id = btn.getAttribute('data-id'); var username = btn.getAttribute('data-username'); var pw = prompt('New password for '+username+' (min 6 characters)'); if (!pw || pw.length < 6) { if(pw!==null) alert('Min 6 characters'); return; } authFetch('/api/admin/users/'+id+'/password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: pw}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) alert('Password updated'); else alert(d.error); }); };
|
||||
});
|
||||
}
|
||||
function fetchUsers() {
|
||||
authFetch('/api/admin/users').then(function(r){ return r.json(); }).then(function(d){ renderUsers(d.users); }).catch(function(){});
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
var el = document.getElementById('adminLogs');
|
||||
if (!logs || logs.length === 0) { el.textContent = 'No logs.'; return; }
|
||||
el.innerHTML = logs.map(function(l){ return fmtDate(l.created_at) + ' ' + (l.username || '') + ' ' + escapeHtml(l.action) + (l.details ? ' ' + escapeHtml(l.details) : ''); }).join('\n');
|
||||
}
|
||||
function fetchLogs() {
|
||||
authFetch('/api/admin/logs').then(function(r){ return r.json(); }).then(function(d){ renderLogs(d.logs); }).catch(function(){});
|
||||
}
|
||||
|
||||
document.getElementById('refreshBackupsBtn').onclick = fetchBackups;
|
||||
document.getElementById('uploadBackupBtn').onclick = function(){ document.getElementById('uploadBackupInput').click(); };
|
||||
document.getElementById('uploadBackupInput').onchange = function(){
|
||||
var f = this.files && this.files[0];
|
||||
if(!f) return;
|
||||
var fd = new FormData(); fd.append('file', f);
|
||||
authFetch('/api/backups/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); alert('Uploaded '+d.name); } else alert(d.error); }).catch(function(){});
|
||||
this.value = '';
|
||||
};
|
||||
document.getElementById('refreshCloudinitBtn').onclick = fetchCloudinit;
|
||||
document.getElementById('refreshLogsBtn').onclick = fetchLogs;
|
||||
document.getElementById('addUserBtn').onclick = function(){ document.getElementById('addUserForm').style.display = 'block'; };
|
||||
document.getElementById('addUserCancel').onclick = function(){ document.getElementById('addUserForm').style.display = 'none'; };
|
||||
document.getElementById('addUserSubmit').onclick = function(){
|
||||
var username = document.getElementById('newUsername').value.trim();
|
||||
var password = document.getElementById('newPassword').value;
|
||||
if(!username) { alert('Username required'); return; }
|
||||
if(password.length < 6) { alert('Password min 6 characters'); return; }
|
||||
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); });
|
||||
};
|
||||
|
||||
fetchBackups(); fetchCloudinit(); fetchUsers(); fetchLogs(); fetchGolden();
|
||||
setInterval(fetchLogs, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
213
emmc-provisioning/dashboard/templates/cloudinit_build.html
Normal file
213
emmc-provisioning/dashboard/templates/cloudinit_build.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cloud-init build · Admin · CM4 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">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
||||
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
||||
--border: #2d333b; --radius: 10px; --radius-sm: 6px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
||||
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
||||
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
||||
.nav a:hover { color: var(--accent); }
|
||||
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
||||
.header a:hover { color: var(--accent); }
|
||||
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; }
|
||||
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
||||
.btn-primary:hover { background: var(--accent-dim); }
|
||||
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
||||
.tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.tabs button { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-dim); cursor: pointer; font: inherit; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
textarea { width: 100%; min-height: 220px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.5rem; border-radius: 4px; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
||||
input[type="text"], select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
||||
label { display: inline-block; margin-right: 0.5rem; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
ul li { margin-bottom: 0.35rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>Cloud-init build</h1>
|
||||
<nav class="nav" style="margin-top:0.35rem;">
|
||||
<a href="/admin">Admin</a>
|
||||
<a href="/admin/portal-files">Portal files</a>
|
||||
<a href="/admin/cloudinit-build" class="active">Cloud-init build</a>
|
||||
</nav>
|
||||
</div>
|
||||
<span>Logged in as <strong>{{ username }}</strong></span>
|
||||
<a href="/">Deploy</a>
|
||||
<a href="/logout">Log out</a>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Download & build cloud-init image</h2>
|
||||
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> on the Admin page.</p>
|
||||
<div style="margin-bottom:0.5rem;">
|
||||
<label>Variant:</label>
|
||||
<select id="buildVariant"><option value="lite">Lite</option><option value="full">Full</option></select>
|
||||
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
|
||||
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build</button></div>
|
||||
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Edit cloud-init files (templates)</h2>
|
||||
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Choose a file to edit below. These contents are injected into the image when you run the build.</p>
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab-btn active" data-tab="user-data">user-data</button>
|
||||
<button type="button" class="tab-btn" data-tab="meta-data">meta-data</button>
|
||||
<button type="button" class="tab-btn" data-tab="network-config">network-config</button>
|
||||
</div>
|
||||
<div id="pane-user-data" class="tab-pane active">
|
||||
<label>user-data (YAML, #cloud-config)</label>
|
||||
<textarea id="buildUserData" rows="12" placeholder="#cloud-config..."></textarea>
|
||||
</div>
|
||||
<div id="pane-meta-data" class="tab-pane">
|
||||
<label>meta-data (optional)</label>
|
||||
<textarea id="buildMetaData" rows="8" placeholder="instance-id: ..."></textarea>
|
||||
</div>
|
||||
<div id="pane-network-config" class="tab-pane">
|
||||
<label>network-config (optional)</label>
|
||||
<textarea id="buildNetworkConfig" rows="8" placeholder="version: 2..."></textarea>
|
||||
</div>
|
||||
<div style="margin-top:0.75rem;">
|
||||
<span>Templates:</span>
|
||||
<select id="buildTemplateSelect"><option value="">— Load —</option></select>
|
||||
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
|
||||
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button>
|
||||
</div>
|
||||
<ul id="buildTemplateList" style="margin-top:0.5rem; font-size:0.85rem;"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function authFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
return fetch(url, opts).then(function(r) {
|
||||
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
||||
return r;
|
||||
});
|
||||
}
|
||||
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-pane').forEach(function(p) { p.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
var tab = btn.getAttribute('data-tab');
|
||||
var pane = document.getElementById('pane-' + tab);
|
||||
if (pane) pane.classList.add('active');
|
||||
};
|
||||
});
|
||||
|
||||
function fetchBuildStatus() {
|
||||
authFetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
|
||||
var el = document.getElementById('buildCloudInitStatus');
|
||||
var btn = document.getElementById('buildCloudInitBtn');
|
||||
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
|
||||
if (btn) btn.disabled = busy;
|
||||
if (d.phase === 'idle' && !d.message) el.textContent = '';
|
||||
else if (d.phase === 'done') { el.textContent = 'Done: ' + (d.output_name || '') + ' — see Admin page Cloud-init images.'; }
|
||||
else if (d.phase === 'error') el.textContent = 'Error: ' + (d.error || '');
|
||||
else el.textContent = (d.phase || '') + ': ' + (d.message || '');
|
||||
if (busy) setTimeout(fetchBuildStatus, 5000);
|
||||
}).catch(function() {});
|
||||
}
|
||||
function startBuild() {
|
||||
var btn = document.getElementById('buildCloudInitBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
|
||||
body.user_data = document.getElementById('buildUserData').value.trim();
|
||||
body.meta_data = document.getElementById('buildMetaData').value.trim();
|
||||
body.network_config = document.getElementById('buildNetworkConfig').value.trim();
|
||||
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
|
||||
else { alert(d.error); if (btn) btn.disabled = false; }
|
||||
}).catch(function() { if (btn) btn.disabled = false; });
|
||||
}
|
||||
function fetchTemplates() {
|
||||
authFetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) {
|
||||
var list = d.templates || [];
|
||||
var sel = document.getElementById('buildTemplateSelect');
|
||||
sel.innerHTML = '<option value="">— Load —</option>';
|
||||
list.forEach(function(t) { var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
|
||||
var ul = document.getElementById('buildTemplateList');
|
||||
ul.innerHTML = list.map(function(t) {
|
||||
return '<li>' + escapeHtml(t.name) + ' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="' + t.id + '">Load</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="' + t.id + '">Delete</button></li>';
|
||||
}).join('') || '<li>No templates</li>';
|
||||
ul.querySelectorAll('.load-tpl').forEach(function(b) {
|
||||
b.onclick = function() {
|
||||
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id')).then(function(r) { return r.json(); }).then(function(t) {
|
||||
document.getElementById('buildUserData').value = t.user_data || '';
|
||||
document.getElementById('buildMetaData').value = t.meta_data || '';
|
||||
document.getElementById('buildNetworkConfig').value = t.network_config || '';
|
||||
});
|
||||
};
|
||||
});
|
||||
ul.querySelectorAll('.del-tpl').forEach(function(b) {
|
||||
b.onclick = function() {
|
||||
if (!confirm('Delete template?')) return;
|
||||
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) fetchTemplates(); });
|
||||
};
|
||||
});
|
||||
}).catch(function() {});
|
||||
}
|
||||
function loadTemplateFromSelect() {
|
||||
var s = document.getElementById('buildTemplateSelect');
|
||||
if (s && s.value) authFetch('/api/cloudinit-templates/' + s.value).then(function(r) { return r.json(); }).then(function(t) {
|
||||
document.getElementById('buildUserData').value = t.user_data || '';
|
||||
document.getElementById('buildMetaData').value = t.meta_data || '';
|
||||
document.getElementById('buildNetworkConfig').value = t.network_config || '';
|
||||
});
|
||||
}
|
||||
function saveTemplate() {
|
||||
var name = prompt('Template name');
|
||||
if (!name || !name.trim()) return;
|
||||
var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value };
|
||||
authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); });
|
||||
}
|
||||
|
||||
document.getElementById('buildCloudInitBtn').onclick = startBuild;
|
||||
document.getElementById('buildVariant').onchange = function() {
|
||||
authFetch('/api/raspios-latest-url?variant=' + encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r) { return r.json(); }).then(function(d) {
|
||||
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
|
||||
});
|
||||
};
|
||||
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
|
||||
document.getElementById('buildTemplateSave').onclick = saveTemplate;
|
||||
|
||||
fetchBuildStatus();
|
||||
fetchTemplates();
|
||||
authFetch('/api/raspios-latest-url').then(function(r) { return r.json(); }).then(function(d) {
|
||||
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
|
||||
});
|
||||
setInterval(fetchBuildStatus, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
224
emmc-provisioning/dashboard/templates/home.html
Normal file
224
emmc-provisioning/dashboard/templates/home.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Deploy · CM4 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">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-secondary: #11151c;
|
||||
--bg-card: #151a24;
|
||||
--accent: #00d4aa;
|
||||
--accent-dim: #00b894;
|
||||
--text: #e6e8eb;
|
||||
--text-dim: #8b949e;
|
||||
--text-muted: #5c6370;
|
||||
--border: #2d333b;
|
||||
--danger: #f87171;
|
||||
--warn: #fbbf24;
|
||||
--success: #00d4aa;
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.45; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.header a { color: var(--text-dim); text-decoration: none; font-size: 0.9rem; }
|
||||
.header a:hover { color: var(--accent); }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
.card-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.6rem; }
|
||||
.status-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.status-pill { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; padding: 0.25rem 0.6rem; border-radius: 999px; }
|
||||
.status-pill.idle { background: rgba(0,212,170,0.15); color: var(--accent); }
|
||||
.status-pill.waiting_choice, .status-pill.rpiboot, .status-pill.flashing, .status-pill.backup { background: rgba(251,191,36,0.15); color: var(--warn); }
|
||||
.status-pill.done { background: rgba(0,212,170,0.15); color: var(--success); }
|
||||
.status-pill.error { background: rgba(248,113,113,0.15); color: var(--danger); }
|
||||
.status-msg { flex: 1; min-width: 0; font-size: 0.9rem; }
|
||||
.status-err { width: 100%; margin-top: 0.4rem; font-size: 0.8rem; color: var(--danger); }
|
||||
.progress-track { margin-top: 0.5rem; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; }
|
||||
.progress-fill.indeterminate { width: 35%; animation: slide 1.2s ease-in-out infinite; }
|
||||
@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
|
||||
.device-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.device-desc { font-size: 0.85rem; color: var(--text-dim); }
|
||||
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
||||
.btn-primary:hover { background: var(--accent-dim); }
|
||||
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
||||
.golden-info { font-size: 0.85rem; color: var(--text-dim); }
|
||||
.golden-info strong { color: var(--text); }
|
||||
.empty-msg { text-align: center; padding: 1rem; font-size: 0.85rem; color: var(--text-muted); }
|
||||
details { margin-top: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
summary { padding: 0.5rem 0.75rem; font-size: 0.85rem; font-weight: 500; color: var(--text-dim); cursor: pointer; list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
|
||||
details[open] summary::before { transform: rotate(90deg); }
|
||||
details .inner { padding: 0.75rem; padding-top: 0; font-size: 0.8rem; color: var(--text-dim); border-top: 1px solid var(--border); }
|
||||
.log-pre { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-sm); padding: 0.5rem 0.75rem; max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: var(--text-muted); }
|
||||
.steps-list { list-style: none; }
|
||||
.steps-list li { display: flex; gap: 0.5rem; padding: 0.3rem 0; }
|
||||
.steps-list .num { flex-shrink: 0; width: 1.2rem; height: 1.2rem; background: var(--bg-secondary); color: var(--text-dim); border-radius: 50%; font-size: 0.7rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.steps-list strong { color: var(--text); }
|
||||
.help-sub { font-weight: 600; color: var(--text); margin: 0.5rem 0 0.2rem 0; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<h1>CM4 eMMC Provisioning</h1>
|
||||
<a href="/admin">Admin</a>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Left: Status + Deploy -->
|
||||
<div>
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<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>
|
||||
</div>
|
||||
<div id="statusErr" class="status-err" style="display:none;"></div>
|
||||
<div id="statusClearWrap" style="display:none; margin-top:0.5rem;"><button type="button" id="statusClearBtn" class="btn btn-outline btn-sm">Clear message</button></div>
|
||||
<div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Capture or deploy</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 or register over network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Golden + Log + Connect -->
|
||||
<div>
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<h2 class="card-title">Current deploy image</h2>
|
||||
<p id="goldenInfo" class="golden-info">Loading…</p>
|
||||
</div>
|
||||
|
||||
<details class="card" style="padding: 0;">
|
||||
<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;">
|
||||
<summary>How to connect</summary>
|
||||
<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">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>
|
||||
<p class="help-sub">Network</p>
|
||||
<ol class="steps-list">
|
||||
<li><span class="num">1</span> Enable network boot; device must reach this server.</li>
|
||||
<li><span class="num">2</span> Boot with provisioning client; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const phaseLabels = { idle: 'Idle', rpiboot: 'Connecting', waiting_choice: 'Choose action', flashing: 'Flashing', backup: 'Backing up', done: 'Done', error: 'Error' };
|
||||
function renderStatus(data) {
|
||||
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 || '';
|
||||
const err = document.getElementById('statusErr');
|
||||
if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; }
|
||||
var clearWrap = document.getElementById('statusClearWrap');
|
||||
if (clearWrap) clearWrap.style.display = (phase === 'error' ? 'block' : 'none');
|
||||
const progress = data.progress;
|
||||
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
|
||||
const wrap = document.getElementById('progressWrap');
|
||||
const fill = document.getElementById('progressFill');
|
||||
if (inProgress || (phase === 'done' && progress != null)) {
|
||||
wrap.style.display = 'block';
|
||||
fill.classList.remove('indeterminate');
|
||||
fill.style.width = (progress != null ? progress + '%' : '35%');
|
||||
if (progress == null) fill.classList.add('indeterminate');
|
||||
} else wrap.style.display = 'none';
|
||||
}
|
||||
function renderPending(usb, network) {
|
||||
const container = document.getElementById('pendingDevices');
|
||||
const noPending = document.getElementById('noPending');
|
||||
container.innerHTML = '';
|
||||
let hasAny = false;
|
||||
const shrinkWrap = document.getElementById('shrinkOptionWrap');
|
||||
if (usb) {
|
||||
hasAny = true;
|
||||
shrinkWrap.style.display = 'block';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'device-item';
|
||||
el.innerHTML = '<div class="device-desc">USB device — choose action</div><div><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';
|
||||
(network || []).forEach(function(d) {
|
||||
hasAny = true;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'device-item';
|
||||
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
|
||||
container.appendChild(el);
|
||||
});
|
||||
noPending.style.display = hasAny ? 'none' : 'block';
|
||||
container.querySelectorAll('button[data-action]').forEach(function(btn) {
|
||||
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 === '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(); })
|
||||
.then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); } else alert(data.error || 'Failed'); })
|
||||
.catch(function() { alert('Request failed'); });
|
||||
};
|
||||
});
|
||||
}
|
||||
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
|
||||
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
|
||||
function fetchStatus() { fetch('/api/status').then(function(r){ return r.json(); }).then(renderStatus).catch(function(){ renderStatus({ phase: 'error', message: 'Could not load status.' }); }); }
|
||||
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 fetchGolden() {
|
||||
fetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
|
||||
const el = document.getElementById('goldenInfo');
|
||||
if (!d.present) { el.innerHTML = 'No image set. An admin must set a backup or cloud-init image as golden for Deploy.'; return; }
|
||||
let t = 'Ready for deploy: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime);
|
||||
if (d.name) t += ' <br><strong>' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ':</strong> ' + escapeHtml(d.name);
|
||||
el.innerHTML = t;
|
||||
}).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; });
|
||||
}
|
||||
document.getElementById('statusClearBtn').addEventListener('click', function(){ fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); });
|
||||
fetchStatus(); fetchLog(); fetchPending(); fetchGolden();
|
||||
setInterval(fetchStatus, 2000);
|
||||
setInterval(fetchLog, 4000);
|
||||
setInterval(fetchPending, 2000);
|
||||
setInterval(fetchGolden, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
973
emmc-provisioning/dashboard/templates/index.html
Normal file
973
emmc-provisioning/dashboard/templates/index.html
Normal file
@@ -0,0 +1,973 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CM4 eMMC 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">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-secondary: #11151c;
|
||||
--bg-tertiary: #1a1f2b;
|
||||
--bg-card: #151a24;
|
||||
--accent: #00d4aa;
|
||||
--accent-dim: #00b894;
|
||||
--accent-glow: rgba(0, 212, 170, 0.12);
|
||||
--text: #e6e8eb;
|
||||
--text-dim: #8b949e;
|
||||
--text-muted: #5c6370;
|
||||
--border: #2d333b;
|
||||
--danger: #f87171;
|
||||
--danger-bg: rgba(248, 113, 113, 0.1);
|
||||
--warn: #fbbf24;
|
||||
--warn-bg: rgba(251, 191, 36, 0.1);
|
||||
--success: #00d4aa;
|
||||
--success-bg: rgba(0, 212, 170, 0.1);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ----- Layout ----- */
|
||||
.wrap {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.header p {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ----- Section blocks ----- */
|
||||
.section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ----- Status block ----- */
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.status-pill.idle { background: var(--accent-glow); color: var(--accent); }
|
||||
.status-pill.rpiboot,
|
||||
.status-pill.waiting_choice,
|
||||
.status-pill.flashing,
|
||||
.status-pill.backup { background: var(--warn-bg); color: var(--warn); }
|
||||
.status-pill.done { background: var(--success-bg); color: var(--success); }
|
||||
.status-pill.error { background: var(--danger-bg); color: var(--danger); }
|
||||
.status-msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.status-err {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
.status-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.progress-track {
|
||||
margin-top: 0.75rem;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-fill.indeterminate {
|
||||
width: 35%;
|
||||
animation: slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
/* ----- Pending devices ----- */
|
||||
.devices-list { list-style: none; }
|
||||
.device-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.device-item:last-child { margin-bottom: 0; }
|
||||
.device-info { min-width: 0; }
|
||||
.device-type {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.device-desc { font-size: 0.9rem; color: var(--text); }
|
||||
.device-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-outline:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-dim);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ----- Backups table ----- */
|
||||
.backups-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backups-table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.backups-table td {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.backups-table tr:last-child td { border-bottom: none; }
|
||||
.backups-table a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.backups-table a:hover { text-decoration: underline; }
|
||||
.backups-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.backup-name-edit, .backup-desc-edit {
|
||||
cursor: text;
|
||||
padding: 0.2rem 0.35rem;
|
||||
margin: -0.2rem -0.35rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.backup-name-edit:hover, .backup-desc-edit:hover { background: var(--bg-tertiary); }
|
||||
.backup-name-edit input, .backup-desc-edit input, .backup-desc-edit textarea {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
}
|
||||
.backup-desc-edit textarea { min-height: 2em; resize: vertical; }
|
||||
.backups-table .actions-cell { white-space: nowrap; }
|
||||
.backups-table .btn-sm { padding: 0.35rem 0.6rem; font-size: 0.8rem; }
|
||||
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
|
||||
.backups-table a.download-link { margin-right: 0.5rem; }
|
||||
.backup-deploy-hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.placeholder-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.placeholder-actions .btns { display: flex; gap: 0.5rem; }
|
||||
.placeholder-actions .btn { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
/* ----- Help & Log (collapsible) ----- */
|
||||
details {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
details[open] { border-color: var(--text-muted); }
|
||||
summary {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before {
|
||||
content: '▶';
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
details[open] summary::before { transform: rotate(90deg); }
|
||||
details .inner {
|
||||
padding: 1rem;
|
||||
padding-top: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.steps-list { list-style: none; }
|
||||
.steps-list li {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.steps-list .num {
|
||||
flex-shrink: 0;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.steps-list strong { color: var(--text); }
|
||||
.help-sub { font-weight: 600; color: var(--text); margin: 0.75rem 0 0.25rem 0; }
|
||||
.log-pre {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.log-pre:empty::before { content: 'No log output yet.'; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrap { padding: 1rem; }
|
||||
.device-item { flex-direction: column; align-items: flex-start; }
|
||||
.device-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<h1>CM4 eMMC Provisioning</h1>
|
||||
<p>Deploy or backup reTerminal via USB boot mode or network</p>
|
||||
</header>
|
||||
|
||||
<!-- 1. Current status -->
|
||||
<section class="section">
|
||||
<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>
|
||||
</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 (or register over network); 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.
|
||||
<button type="button" id="statusClearBtn" 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>
|
||||
<div id="progressWrap" class="progress-track" style="display:none;">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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 or register over network. 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>
|
||||
<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>
|
||||
<div id="pendingDevices"></div>
|
||||
<div id="noPendingPlaceholder" class="placeholder-actions" style="display:none;">
|
||||
<span>Connect a device to see:</span>
|
||||
<span class="btns">
|
||||
<button type="button" class="btn btn-outline btn-sm" disabled>Backup</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" disabled>Deploy</button>
|
||||
</span>
|
||||
<span>— USB boot mode or network registration</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) or register a network-booted device — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p>
|
||||
</section>
|
||||
|
||||
<!-- 3. Saved backups -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Saved backups
|
||||
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn" title="Reload list">Refresh</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="uploadImageBtn" title="Upload an image file">Upload image</button>
|
||||
<input type="file" id="uploadImageInput" accept=".img,.img.gz,.img.xz,image/*" style="display:none;" />
|
||||
</h2>
|
||||
<p id="goldenHint" class="backups-mono" style="margin-bottom:0.25rem;font-size:0.8rem;"></p>
|
||||
<p id="backupsDirHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.75rem;color:var(--muted);"></p>
|
||||
<table class="backups-table" id="backupsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Size</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="backupsBody"></tbody>
|
||||
</table>
|
||||
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups yet. Capture one from a device (Backup above), then set it as golden for future deploys.</p>
|
||||
</section>
|
||||
|
||||
<!-- 3b. Build cloud-init image from official Raspberry Pi OS -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Build cloud-init image</h2>
|
||||
<p class="backup-deploy-hint">Download the latest <strong>Raspberry Pi OS (arm64)</strong> from the official repository and inject cloud-init NoCloud files. The built image appears in Saved backups; you can then choose it and click <strong>Set as golden</strong> for deployment.</p>
|
||||
<div style="margin-bottom:0.5rem;">
|
||||
<label>Variant: </label>
|
||||
<select id="buildVariant">
|
||||
<option value="lite">Lite (no desktop)</option>
|
||||
<option value="full">Full (desktop)</option>
|
||||
</select>
|
||||
<span id="buildRaspiosUrl" class="backups-mono" style="font-size:0.8rem; margin-left:0.5rem;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom:0.5rem;">
|
||||
<label><input type="checkbox" id="buildSetGolden" /> Set as golden image after build</label>
|
||||
<span class="backups-mono" style="font-size:0.8rem;"> (use for Deploy without clicking manually)</span>
|
||||
</div>
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build cloud-init image</button>
|
||||
</div>
|
||||
<div id="buildCloudInitStatus" class="backups-mono" style="font-size:0.85rem; min-height:1.5em; margin-bottom:0.5rem;"></div>
|
||||
<details style="margin-top:0.5rem;">
|
||||
<summary>Cloud-init templates & customize</summary>
|
||||
<div class="inner" style="margin-top:0.5rem;">
|
||||
<p><strong>Templates:</strong> <select id="buildTemplateSelect"><option value="">— Load a template —</option></select>
|
||||
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
|
||||
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save current as template…</button></p>
|
||||
<ul id="buildTemplateList" class="backups-mono" style="font-size:0.85rem; list-style:none; padding:0;"></ul>
|
||||
<label>user-data (YAML)</label>
|
||||
<textarea id="buildUserData" rows="8" style="width:100%; font-family:monospace; font-size:0.85rem; margin-bottom:0.5rem;" placeholder="Leave empty to use default (remote bootstrap example)"></textarea>
|
||||
<label>meta-data (optional)</label>
|
||||
<textarea id="buildMetaData" rows="3" style="width:100%; font-family:monospace; font-size:0.85rem; margin-bottom:0.5rem;"></textarea>
|
||||
<label>network-config (optional)</label>
|
||||
<textarea id="buildNetworkConfig" rows="5" style="width:100%; font-family:monospace; font-size:0.85rem;"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- 4. How to connect (collapsible) -->
|
||||
<details class="section" style="padding:0;">
|
||||
<summary>How to connect</summary>
|
||||
<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">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>
|
||||
<p class="help-sub">Network boot</p>
|
||||
<ol class="steps-list">
|
||||
<li><span class="num">1</span> Enable network boot (e.g. <code style="background:var(--bg-tertiary);padding:0.15rem 0.35rem;border-radius:4px;">BOOT_ORDER=0x21</code>) and ensure the device can reach this server.</li>
|
||||
<li><span class="num">2</span> Boot with the provisioning client; it will show above. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 5. Recent log (collapsible) -->
|
||||
<details class="section" style="padding:0;">
|
||||
<summary>Recent log</summary>
|
||||
<div class="inner">
|
||||
<pre id="log" class="log-pre"></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const statusPill = document.getElementById('statusPill');
|
||||
const statusMsg = document.getElementById('statusMsg');
|
||||
const statusErr = document.getElementById('statusErr');
|
||||
const statusMeta = document.getElementById('statusMeta');
|
||||
const progressWrap = document.getElementById('progressWrap');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
|
||||
const phaseLabels = {
|
||||
idle: 'Idle',
|
||||
rpiboot: 'Connecting',
|
||||
waiting_choice: 'Choose action',
|
||||
flashing: 'Flashing',
|
||||
backup: 'Backing up',
|
||||
done: 'Done',
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
function renderStatus(data) {
|
||||
const phase = data.phase || 'idle';
|
||||
statusPill.className = 'status-pill ' + phase;
|
||||
statusPill.textContent = phaseLabels[phase] || phase;
|
||||
statusMsg.textContent = data.message || '';
|
||||
|
||||
if (data.error) {
|
||||
statusErr.textContent = data.error;
|
||||
statusErr.style.display = 'block';
|
||||
} else {
|
||||
statusErr.style.display = 'none';
|
||||
}
|
||||
|
||||
var goldenHint = document.getElementById('statusGoldenHint');
|
||||
if (goldenHint) {
|
||||
var isGoldenError = phase === 'error' && /golden|Golden image/i.test((data.error || '') + (data.message || ''));
|
||||
goldenHint.style.display = isGoldenError ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (data.updated) {
|
||||
statusMeta.textContent = 'Updated ' + data.updated;
|
||||
statusMeta.style.display = 'block';
|
||||
} else {
|
||||
statusMeta.style.display = 'none';
|
||||
}
|
||||
|
||||
const progress = data.progress;
|
||||
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
|
||||
if (inProgress || (phase === 'done' && progress != null)) {
|
||||
progressWrap.style.display = 'block';
|
||||
progressFill.classList.remove('indeterminate');
|
||||
if (progress != null) {
|
||||
progressFill.style.width = progress + '%';
|
||||
} else {
|
||||
progressFill.classList.add('indeterminate');
|
||||
progressFill.style.width = '35%';
|
||||
}
|
||||
} else {
|
||||
progressWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPending(usb, network) {
|
||||
const container = document.getElementById('pendingDevices');
|
||||
const noPending = document.getElementById('noPending');
|
||||
container.innerHTML = '';
|
||||
let hasAny = false;
|
||||
|
||||
const shrinkWrap = document.getElementById('shrinkOptionWrap');
|
||||
if (usb) {
|
||||
hasAny = true;
|
||||
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 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';
|
||||
}
|
||||
(network || []).forEach(function(d) {
|
||||
hasAny = true;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'device-item';
|
||||
el.innerHTML = '<div class="device-info"><div class="device-type">Network</div><div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
|
||||
container.appendChild(el);
|
||||
});
|
||||
|
||||
const placeholder = document.getElementById('noPendingPlaceholder');
|
||||
noPending.style.display = hasAny ? 'none' : 'block';
|
||||
if (placeholder) placeholder.style.display = hasAny ? 'none' : 'flex';
|
||||
|
||||
container.querySelectorAll('button[data-action]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const source = btn.getAttribute('data-source');
|
||||
const action = btn.getAttribute('data-action');
|
||||
const mac = btn.getAttribute('data-mac');
|
||||
const body = { source: source, action: action };
|
||||
if (mac) body.mac = mac;
|
||||
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) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchPending(); fetchStatus(); }
|
||||
else alert(data.error || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderBackups(backups) {
|
||||
const tbody = document.getElementById('backupsBody');
|
||||
const empty = document.getElementById('backupsEmpty');
|
||||
tbody.innerHTML = '';
|
||||
if (!backups || backups.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
backups.forEach(function(b) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.name = b.name;
|
||||
const displayName = (b.display_name || b.name);
|
||||
const desc = (b.description || '');
|
||||
const isRawImg = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
|
||||
const shrinkBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="' + escapeHtml(b.name) + '" title="Shrink image (PiShrink)">Shrink</button> ' : '';
|
||||
const compressBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm compress-btn" data-name="' + escapeHtml(b.name) + '" data-format="xz" title="Shrink and compress to .img.xz (minimum size)">Compress</button> ' : '';
|
||||
tr.innerHTML =
|
||||
'<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' +
|
||||
'<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' +
|
||||
'<td class="backups-mono">' + fmtSize(b.size) + '</td>' +
|
||||
'<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' +
|
||||
'<td class="actions-cell">' +
|
||||
shrinkBtn +
|
||||
compressBtn +
|
||||
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
|
||||
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
|
||||
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a> ' +
|
||||
'<button type="button" class="btn btn-outline btn-sm delete-backup-btn" data-name="' + escapeHtml(b.name) + '" title="Delete this backup">Delete</button>' +
|
||||
'</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
bindBackupEdits();
|
||||
bindSetGolden();
|
||||
bindRenameFile();
|
||||
bindShrink();
|
||||
bindCompress();
|
||||
bindDeleteBackup();
|
||||
}
|
||||
|
||||
function bindDeleteBackup() {
|
||||
document.querySelectorAll('.delete-backup-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const name = btn.getAttribute('data-name');
|
||||
if (!confirm('Delete this backup? This cannot be undone.\n\n' + name)) return;
|
||||
btn.disabled = true;
|
||||
fetch('/api/backups/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchBackups(); fetchGoldenInfo(); }
|
||||
else alert(data.error || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); })
|
||||
.finally(function() { btn.disabled = false; });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bindRenameFile() {
|
||||
document.querySelectorAll('.rename-file-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const name = btn.getAttribute('data-name');
|
||||
const newName = prompt('New filename (e.g. production-v1.img)', name);
|
||||
if (newName == null || newName.trim() === '') return;
|
||||
const n = newName.trim();
|
||||
if (!/\.(img|img\.gz|img\.xz)$/i.test(n)) { alert('Filename must end with .img, .img.gz or .img.xz'); return; }
|
||||
if (n === name) return;
|
||||
fetch('/api/backups/' + encodeURIComponent(name), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: n }) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) fetchBackups();
|
||||
else alert(data.error || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bindBackupEdits() {
|
||||
document.querySelectorAll('.backup-name-edit[data-field], .backup-desc-edit[data-field]').forEach(function(cell) {
|
||||
if (cell._bound) return;
|
||||
cell._bound = true;
|
||||
cell.addEventListener('click', function() {
|
||||
if (cell.querySelector('input, textarea')) return;
|
||||
const field = cell.getAttribute('data-field');
|
||||
const row = cell.closest('tr');
|
||||
const filename = row.dataset.name;
|
||||
const isDesc = field === 'description';
|
||||
const current = cell.textContent.trim();
|
||||
const input = document.createElement(isDesc ? 'textarea' : 'input');
|
||||
input.type = isDesc ? 'text' : 'text';
|
||||
input.value = current;
|
||||
input.placeholder = isDesc ? 'Add a description…' : 'Name';
|
||||
cell.textContent = '';
|
||||
cell.appendChild(input);
|
||||
input.focus();
|
||||
if (isDesc) input.rows = 2;
|
||||
function save() {
|
||||
const val = input.value.trim();
|
||||
const body = isDesc ? { description: val } : { name: val || filename };
|
||||
fetch('/api/backups/' + encodeURIComponent(filename), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchBackups(); }
|
||||
else { alert(data.error || 'Failed'); cell.innerHTML = escapeHtml(current); }
|
||||
})
|
||||
.catch(function() { cell.innerHTML = escapeHtml(current); });
|
||||
}
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !isDesc) { e.preventDefault(); input.blur(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindSetGolden() {
|
||||
document.querySelectorAll('.set-golden-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const name = btn.getAttribute('data-name');
|
||||
if (!confirm('Set this backup as the golden image? Future deploys will use it.\n\n' + name)) return;
|
||||
fetch('/api/backups/' + encodeURIComponent(name) + '/set-as-golden', { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchBackups(); fetchGoldenInfo(); }
|
||||
else alert(data.error || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bindShrink() {
|
||||
document.querySelectorAll('.shrink-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const name = btn.getAttribute('data-name');
|
||||
if (!confirm('Shrink this image with PiShrink? This reduces file size and may take a few minutes.\n\n' + name)) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Shrinking…';
|
||||
fetch('/api/backups/' + encodeURIComponent(name) + '/shrink', { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchBackups(); }
|
||||
else alert(data.error || data.detail || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); })
|
||||
.finally(function() { btn.disabled = false; btn.textContent = 'Shrink'; });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bindCompress() {
|
||||
document.querySelectorAll('.compress-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const name = btn.getAttribute('data-name');
|
||||
const format = btn.getAttribute('data-format') || 'xz';
|
||||
if (!confirm('Shrink and compress this image to .img.' + format + '? This minimizes size and may take several minutes.\n\n' + name)) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Compressing…';
|
||||
fetch('/api/backups/' + encodeURIComponent(name) + '/compress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: format }) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchBackups(); }
|
||||
else alert(data.error || data.detail || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); })
|
||||
.finally(function() { btn.disabled = false; btn.textContent = 'Compress'; });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fetchGoldenInfo() {
|
||||
fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) {
|
||||
const el = document.getElementById('goldenHint');
|
||||
el.textContent = d.present ? ('Golden image: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime)) : 'No golden image set. Capture a backup and click "Set as golden" to use it for Deploy.';
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
fetch('/api/status').then(function(r) { return r.json(); }).then(renderStatus).catch(function() { renderStatus({ phase: 'error', message: 'Could not load status.' }); });
|
||||
}
|
||||
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 fetchBackups() {
|
||||
fetch('/api/backups').then(function(r) { return r.json(); }).then(function(d) {
|
||||
renderBackups(d.backups || []);
|
||||
var dirEl = document.getElementById('backupsDirHint');
|
||||
if (dirEl && d.backups_dir) dirEl.textContent = 'Stored in: ' + d.backups_dir;
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function getBuildVariant() {
|
||||
var sel = document.getElementById('buildVariant');
|
||||
return (sel && sel.value) ? sel.value : 'lite';
|
||||
}
|
||||
function fetchRaspiosUrl() {
|
||||
var variant = getBuildVariant();
|
||||
fetch('/api/raspios-latest-url?variant=' + encodeURIComponent(variant)).then(function(r) { return r.json(); }).then(function(d) {
|
||||
var el = document.getElementById('buildRaspiosUrl');
|
||||
if (el) el.textContent = d.ok && d.filename ? ('Latest: ' + d.filename) : (d.error || 'Could not resolve latest image URL');
|
||||
}).catch(function() {});
|
||||
}
|
||||
function fetchCloudInitTemplates() {
|
||||
fetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) {
|
||||
var list = d.templates || [];
|
||||
var sel = document.getElementById('buildTemplateSelect');
|
||||
var listEl = document.getElementById('buildTemplateList');
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">— Load a template —</option>';
|
||||
list.forEach(function(t) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (listEl) {
|
||||
listEl.innerHTML = list.map(function(t) {
|
||||
return '<li><span>' + escapeHtml(t.name) + '</span> <button type="button" class="btn btn-outline btn-sm template-load-btn" data-id="' + escapeHtml(t.id) + '">Load</button> <button type="button" class="btn btn-outline btn-sm template-del-btn" data-id="' + escapeHtml(t.id) + '">Delete</button></li>';
|
||||
}).join('') || '<li>No templates saved.</li>';
|
||||
listEl.querySelectorAll('.template-load-btn').forEach(function(btn) {
|
||||
btn.onclick = function() { loadTemplate(btn.getAttribute('data-id')); };
|
||||
});
|
||||
listEl.querySelectorAll('.template-del-btn').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
fetch('/api/cloudinit-templates/' + encodeURIComponent(btn.getAttribute('data-id')), { method: 'DELETE' })
|
||||
.then(function(r) { return r.json(); }).then(function(data) { if (data.ok) fetchCloudInitTemplates(); });
|
||||
};
|
||||
});
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
function loadTemplate(id) {
|
||||
fetch('/api/cloudinit-templates/' + encodeURIComponent(id)).then(function(r) { return r.json(); }).then(function(t) {
|
||||
var ud = document.getElementById('buildUserData');
|
||||
var md = document.getElementById('buildMetaData');
|
||||
var nc = document.getElementById('buildNetworkConfig');
|
||||
if (ud) ud.value = t.user_data || '';
|
||||
if (md) md.value = t.meta_data || '';
|
||||
if (nc) nc.value = t.network_config || '';
|
||||
}).catch(function() {});
|
||||
}
|
||||
function saveTemplate() {
|
||||
var name = prompt('Template name');
|
||||
if (!name || !name.trim()) return;
|
||||
var ud = document.getElementById('buildUserData');
|
||||
var md = document.getElementById('buildMetaData');
|
||||
var nc = document.getElementById('buildNetworkConfig');
|
||||
fetch('/api/cloudinit-templates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
user_data: ud ? ud.value : '',
|
||||
meta_data: md ? md.value : '',
|
||||
network_config: nc ? nc.value : ''
|
||||
})}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.ok) { fetchCloudInitTemplates(); alert('Saved as "' + name + '"'); }
|
||||
else alert(d.error || 'Failed');
|
||||
}).catch(function() { alert('Failed'); });
|
||||
}
|
||||
|
||||
function fetchBuildStatus() {
|
||||
fetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
|
||||
var el = document.getElementById('buildCloudInitStatus');
|
||||
var btn = document.getElementById('buildCloudInitBtn');
|
||||
if (!el) return;
|
||||
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
|
||||
if (btn) btn.disabled = busy;
|
||||
if (d.phase === 'idle' && !d.message) {
|
||||
el.textContent = '';
|
||||
} else if (d.phase === 'done') {
|
||||
el.textContent = 'Done: ' + (d.output_name || '') + ' — see Saved backups above. Click "Set as golden" next to it to use for Deploy. If missing, click Refresh.';
|
||||
fetchBackups();
|
||||
fetchGoldenInfo();
|
||||
} else if (d.phase === 'error') {
|
||||
el.textContent = 'Error: ' + (d.error || d.message || 'Unknown');
|
||||
} else {
|
||||
el.textContent = (d.phase || '') + ': ' + (d.message || '');
|
||||
}
|
||||
if (busy) setTimeout(fetchBuildStatus, 5000);
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function startBuildCloudInit() {
|
||||
var btn = document.getElementById('buildCloudInitBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
var ud = document.getElementById('buildUserData');
|
||||
var md = document.getElementById('buildMetaData');
|
||||
var nc = document.getElementById('buildNetworkConfig');
|
||||
var setGolden = document.getElementById('buildSetGolden');
|
||||
var body = {
|
||||
variant: getBuildVariant(),
|
||||
set_as_golden_after: setGolden && setGolden.checked,
|
||||
user_data: (ud && ud.value.trim()) ? ud.value.trim() : undefined,
|
||||
meta_data: (md && md.value.trim()) ? md.value.trim() : undefined,
|
||||
network_config: (nc && nc.value.trim()) ? nc.value.trim() : undefined
|
||||
};
|
||||
fetch('/api/build-cloudinit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
document.getElementById('buildCloudInitStatus').textContent = 'Build started on host. Waiting…';
|
||||
setTimeout(fetchBuildStatus, 2000);
|
||||
} else {
|
||||
alert(data.error || 'Failed to start build');
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('Request failed'); if (btn) btn.disabled = false; });
|
||||
}
|
||||
|
||||
function fmtSize(n) {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GB';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB';
|
||||
return (n / 1e3).toFixed(0) + ' KB';
|
||||
}
|
||||
function fmtDate(ts) { return new Date(ts * 1000).toLocaleString(); }
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
document.getElementById('statusClearBtn').addEventListener('click', function() {
|
||||
fetch('/api/status-clear', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) fetchStatus(); });
|
||||
});
|
||||
var refreshBackupsBtn = document.getElementById('refreshBackupsBtn');
|
||||
if (refreshBackupsBtn) refreshBackupsBtn.onclick = function() { fetchBackups(); fetchGoldenInfo(); };
|
||||
var uploadImageBtn = document.getElementById('uploadImageBtn');
|
||||
var uploadImageInput = document.getElementById('uploadImageInput');
|
||||
if (uploadImageBtn && uploadImageInput) {
|
||||
uploadImageBtn.onclick = function() { uploadImageInput.click(); };
|
||||
uploadImageInput.onchange = function() {
|
||||
var file = uploadImageInput.files && uploadImageInput.files[0];
|
||||
if (!file) return;
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
uploadImageBtn.disabled = true;
|
||||
fetch('/api/backups/upload', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) { fetchBackups(); fetchGoldenInfo(); alert('Uploaded: ' + (d.name || file.name)); }
|
||||
else alert(d.error || 'Upload failed');
|
||||
})
|
||||
.catch(function() { alert('Upload failed'); })
|
||||
.finally(function() { uploadImageBtn.disabled = false; uploadImageInput.value = ''; });
|
||||
};
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
fetchLog();
|
||||
fetchPending();
|
||||
fetchBackups();
|
||||
fetchGoldenInfo();
|
||||
fetchRaspiosUrl();
|
||||
fetchBuildStatus();
|
||||
fetchCloudInitTemplates();
|
||||
var buildBtn = document.getElementById('buildCloudInitBtn');
|
||||
if (buildBtn) buildBtn.onclick = startBuildCloudInit;
|
||||
var variantSel = document.getElementById('buildVariant');
|
||||
if (variantSel) variantSel.onchange = fetchRaspiosUrl;
|
||||
var templateLoadBtn = document.getElementById('buildTemplateLoad');
|
||||
if (templateLoadBtn) templateLoadBtn.onclick = function() { var s = document.getElementById('buildTemplateSelect'); if (s && s.value) loadTemplate(s.value); };
|
||||
var templateSaveBtn = document.getElementById('buildTemplateSave');
|
||||
if (templateSaveBtn) templateSaveBtn.onclick = saveTemplate;
|
||||
setInterval(fetchStatus, 2000);
|
||||
setInterval(fetchLog, 4000);
|
||||
setInterval(fetchPending, 2000);
|
||||
setInterval(fetchBackups, 5000);
|
||||
setInterval(fetchBuildStatus, 15000);
|
||||
setInterval(fetchGoldenInfo, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
42
emmc-provisioning/dashboard/templates/login.html
Normal file
42
emmc-provisioning/dashboard/templates/login.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin login · CM4 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">
|
||||
<style>
|
||||
:root { --bg: #0a0e14; --bg-card: #151a24; --accent: #00d4aa; --text: #e6e8eb; --text-dim: #8b949e; --border: #2d333b; --danger: #f87171; --radius: 10px; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.box { width: 100%; max-width: 360px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
||||
p.sub { font-size: 0.9rem; color: var(--text-dim); margin-bottom: 1.5rem; }
|
||||
.err { font-size: 0.85rem; color: var(--danger); margin-bottom: 1rem; }
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.35rem; color: var(--text-dim); }
|
||||
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem 0.75rem; font: inherit; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); margin-bottom: 1rem; }
|
||||
input:focus { outline: none; border-color: var(--accent); }
|
||||
button { width: 100%; padding: 0.65rem; font: inherit; font-weight: 600; background: var(--accent); color: var(--bg); border: none; border-radius: 6px; cursor: pointer; }
|
||||
button:hover { background: #00b894; }
|
||||
a.back { display: inline-block; margin-top: 1rem; font-size: 0.9rem; color: var(--text-dim); text-decoration: none; }
|
||||
a.back:hover { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Admin login</h1>
|
||||
<p class="sub">CM4 eMMC provisioning dashboard</p>
|
||||
{% if error %}<p class="err">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="{{ url_for('login') }}">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username" value="{{ request.form.username or '' }}">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<a href="{{ url_for('index') }}" class="back">← Back to deploy</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
215
emmc-provisioning/dashboard/templates/portal_files.html
Normal file
215
emmc-provisioning/dashboard/templates/portal_files.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Portal files · Admin · CM4 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">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
||||
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
||||
--border: #2d333b; --danger: #f87171; --radius: 10px; --radius-sm: 6px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
||||
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
||||
.nav a:hover { color: var(--accent); }
|
||||
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
||||
.header a:hover { color: var(--accent); }
|
||||
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
||||
.btn-primary:hover { background: var(--accent-dim); }
|
||||
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
||||
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
|
||||
.btn-danger:hover { background: rgba(248,113,113,0.3); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
||||
.empty-msg { text-align: center; padding: 1.5rem; font-size: 0.9rem; color: var(--text-muted); }
|
||||
.breadcrumb { margin-bottom: 0.75rem; font-size: 0.85rem; }
|
||||
.breadcrumb a { color: var(--text-dim); text-decoration: none; }
|
||||
.breadcrumb a:hover { color: var(--accent); }
|
||||
.breadcrumb span { color: var(--text-muted); margin: 0 0.25rem; }
|
||||
.desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; }
|
||||
.folder-name { font-weight: 500; }
|
||||
input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>Portal files</h1>
|
||||
<nav class="nav" style="margin-top:0.35rem;">
|
||||
<a href="/admin">Admin</a>
|
||||
<a href="/admin/portal-files" class="active">Portal files</a>
|
||||
<a href="/admin/cloudinit-build">Cloud-init build</a>
|
||||
</nav>
|
||||
</div>
|
||||
<span>Logged in as <strong>{{ username }}</strong></span>
|
||||
<a href="/">Deploy</a>
|
||||
<a href="/logout">Log out</a>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<span id="breadcrumb">Portal files</span>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="newFolderBtn">New folder</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="uploadBtn">Upload file</button>
|
||||
<input type="file" id="uploadInput" style="display:none;" />
|
||||
</div>
|
||||
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Served at <strong id="baseUrl">/files/</strong> — use in cloud-init e.g. <code>curl -fsSL "http://SERVER/files/first-boot/splash.png" -o /tmp/splash.png</code></p>
|
||||
<p class="mono" style="font-size:0.75rem; color:var(--text-muted); margin-bottom:0.75rem;">Directory on server: <strong id="portalDir">—</strong></p>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Description</th><th>Actions</th></tr></thead>
|
||||
<tbody id="portalBody"></tbody>
|
||||
</table>
|
||||
<p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function authFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
opts.credentials = opts.credentials || 'same-origin';
|
||||
return fetch(url, opts).then(function(r) {
|
||||
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
||||
return r;
|
||||
});
|
||||
}
|
||||
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||
function fmtSize(n) { if (!n) return '—'; if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
|
||||
function fmtDate(ts) { return ts ? new Date(ts*1000).toLocaleString() : '—'; }
|
||||
|
||||
var currentPath = '';
|
||||
var descriptions = {};
|
||||
|
||||
function buildBreadcrumb() {
|
||||
var el = document.getElementById('breadcrumb');
|
||||
var parts = currentPath ? currentPath.split('/') : [];
|
||||
var html = '<a href="#" data-path="">Portal files</a>';
|
||||
parts.forEach(function(p, i) {
|
||||
var path = parts.slice(0, i+1).join('/');
|
||||
html += ' <span>/</span> <a href="#" data-path="' + escapeHtml(path) + '">' + escapeHtml(p) + '</a>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
el.querySelectorAll('a[data-path]').forEach(function(a) {
|
||||
a.onclick = function(e) { e.preventDefault(); currentPath = a.getAttribute('data-path') || ''; fetchPortal(); };
|
||||
});
|
||||
}
|
||||
|
||||
function saveDescription(path, desc) {
|
||||
descriptions[path] = desc;
|
||||
authFetch('/api/portal-files/descriptions', { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ descriptions: descriptions }) }).then(function(r) { return r.json(); }).catch(function() {});
|
||||
}
|
||||
|
||||
function renderPortal(data) {
|
||||
var items = data.items || [];
|
||||
var baseUrl = data.base_url || '';
|
||||
descriptions = data.descriptions || {};
|
||||
currentPath = data.current_path || '';
|
||||
document.getElementById('baseUrl').textContent = baseUrl + (currentPath ? currentPath + '/' : '');
|
||||
document.getElementById('portalDir').textContent = data.portal_files_dir || '—';
|
||||
buildBreadcrumb();
|
||||
|
||||
var tbody = document.getElementById('portalBody');
|
||||
var empty = document.getElementById('portalEmpty');
|
||||
tbody.innerHTML = '';
|
||||
if (items.length === 0) { empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
|
||||
items.forEach(function(it) {
|
||||
var tr = document.createElement('tr');
|
||||
var desc = descriptions[it.path] || '';
|
||||
var typeLabel = it.type === 'folder' ? 'Folder' : 'File';
|
||||
var sizeCell = it.type === 'file' ? '<td class="mono">' + fmtSize(it.size) + '</td>' : '<td class="mono">—</td>';
|
||||
var descCell = '<td><input type="text" class="desc-input" data-path="' + escapeHtml(it.path) + '" value="' + escapeHtml(desc) + '" placeholder="Comment…" /></td>';
|
||||
var actions = '';
|
||||
if (it.type === 'folder') {
|
||||
actions = '<button type="button" class="btn btn-outline btn-sm open-folder" data-path="' + escapeHtml(it.path) + '">Open</button> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="folder">Delete</button>';
|
||||
} else {
|
||||
actions = '<a href="' + escapeHtml(baseUrl + it.path) + '" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open</a> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="file">Delete</button>';
|
||||
}
|
||||
tr.innerHTML = '<td class="' + (it.type === 'folder' ? 'folder-name' : '') + '">' + escapeHtml(it.name) + (it.type === 'folder' ? ' /' : '') + '</td><td>' + typeLabel + '</td>' + sizeCell + descCell + '<td class="actions-cell">' + actions + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('.desc-input').forEach(function(inp) {
|
||||
inp.onblur = function() { saveDescription(inp.getAttribute('data-path'), inp.value.trim()); };
|
||||
});
|
||||
tbody.querySelectorAll('.open-folder').forEach(function(btn) {
|
||||
btn.onclick = function() { currentPath = btn.getAttribute('data-path'); fetchPortal(); };
|
||||
});
|
||||
tbody.querySelectorAll('.delete-item').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var path = btn.getAttribute('data-path');
|
||||
var type = btn.getAttribute('data-type');
|
||||
if (!confirm('Delete ' + type + ' "' + path + '"?')) return;
|
||||
authFetch('/api/portal-files/' + encodeURIComponent(path), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.ok) fetchPortal(); else alert(d.error || 'Delete failed');
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fetchPortal() {
|
||||
var url = '/api/portal-files';
|
||||
if (currentPath) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'path=' + encodeURIComponent(currentPath);
|
||||
authFetch(url).then(function(r) { return r.json(); }).then(renderPortal).catch(function(err) {
|
||||
document.getElementById('portalEmpty').style.display = 'block';
|
||||
document.getElementById('portalEmpty').textContent = 'Could not load list (session may have expired). Trying read-only list…';
|
||||
var fallbackUrl = '/api/portal-files?debug=1' + (currentPath ? '&path=' + encodeURIComponent(currentPath) : '');
|
||||
fetch(fallbackUrl).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.items && data.items.length) {
|
||||
document.getElementById('portalEmpty').style.display = 'none';
|
||||
renderPortal(data);
|
||||
document.getElementById('portalDir').textContent = (data.portal_files_dir || '—') + ' (read-only; log in to edit)';
|
||||
} else {
|
||||
document.getElementById('portalEmpty').textContent = 'Server sees ' + (data.items ? data.items.length : 0) + ' item(s) at ' + (data.portal_files_dir || '?') + '. Log in to see and edit.';
|
||||
}
|
||||
}).catch(function() {
|
||||
document.getElementById('portalEmpty').textContent = 'Could not load list. Log out and log in again, then refresh.';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('newFolderBtn').onclick = function() {
|
||||
var name = prompt('Folder name (no slashes)');
|
||||
if (!name || !name.trim()) return;
|
||||
name = name.trim().replace(/[/\\]/g, '');
|
||||
var path = currentPath ? currentPath + '/' + name : name;
|
||||
authFetch('/api/portal-files/folder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ path: path }) }).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.ok) fetchPortal(); else alert(d.error || 'Failed');
|
||||
});
|
||||
};
|
||||
document.getElementById('uploadBtn').onclick = function() { document.getElementById('uploadInput').click(); };
|
||||
document.getElementById('uploadInput').onchange = function() {
|
||||
var f = this.files && this.files[0];
|
||||
if (!f) return;
|
||||
var fd = new FormData();
|
||||
fd.append('file', f);
|
||||
if (currentPath) fd.append('path', currentPath);
|
||||
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.ok) { fetchPortal(); alert('Uploaded. URL: ' + d.url); } else alert(d.error || 'Upload failed');
|
||||
});
|
||||
this.value = '';
|
||||
};
|
||||
|
||||
fetchPortal();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
256
emmc-provisioning/docs/EMMC-PROVISIONING-GUIDE.md
Normal file
256
emmc-provisioning/docs/EMMC-PROVISIONING-GUIDE.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# eMMC provisioning for reTerminal DM4 (CM4)
|
||||
|
||||
This guide covers:
|
||||
|
||||
1. **USB boot mode**: When the reTerminal is in boot mode (eMMC disable jumper) and connected via USB, the host runs **rpiboot** to expose the eMMC, then the **dashboard** shows "Device connected (USB)". You choose **Backup** or **Deploy** in the portal — there is no auto-flash; the action runs only after your choice.
|
||||
2. **Network boot**: If the device boots over the network and runs the **provisioning client** (see `network-client/`), it registers with the dashboard and appears as "Device (Network)"; you then choose Backup or Deploy. Deploy streams the golden image to the device; Backup uploads the device eMMC to the server.
|
||||
3. **Cloud-init**: The golden image can include cloud-init so each device configures itself on first boot (hostname, network, packages, kiosk setup).
|
||||
|
||||
---
|
||||
|
||||
## Part 1: USB boot mode — detect device, choose Backup or Deploy in portal
|
||||
|
||||
### How it works
|
||||
|
||||
- reTerminal has an **eMMC disable** jumper (see reTerminal docs; often “J2” or “nRPIBOOT”). When the jumper is fitted, the CM4 boots in **USB device mode** and waits for `rpiboot` from the host.
|
||||
- You connect the reTerminal’s **USB slave** port to a **provisioning PC** (Linux).
|
||||
- **udev** detects the Raspberry Pi Foundation USB device (vendor `2b8e`) and runs a trigger script.
|
||||
- The trigger starts the **provisioning script** that:
|
||||
1. Runs **rpiboot** (from the `usbboot` project). The CM4 then exposes its eMMC as a USB mass-storage device.
|
||||
2. Finds the new block device (eMMC) and writes status so the **dashboard** shows "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard."
|
||||
3. **Waits for your choice in the portal** — no automatic flash. When you click **Backup** or **Deploy** in the dashboard, the script runs that action (dd backup or dd deploy).
|
||||
- You remove the jumper and power cycle; the reTerminal boots from eMMC and can run **cloud-init** on first boot.
|
||||
|
||||
### Provisioning host setup (Linux)
|
||||
|
||||
#### 1. Build and install usbboot (rpiboot)
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libusb-1.0-0-dev
|
||||
git clone --depth=1 https://github.com/raspberrypi/usbboot
|
||||
cd usbboot
|
||||
make
|
||||
sudo mkdir -p /opt/usbboot
|
||||
sudo cp rpiboot /opt/usbboot/
|
||||
```
|
||||
|
||||
#### 2. Create golden image and config directory
|
||||
|
||||
- Build your golden image (see Part 2) and place it where the script will find it, e.g.:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/lib/cm4-provisioning
|
||||
sudo cp /path/to/your/golden-reterminal.img /var/lib/cm4-provisioning/golden.img
|
||||
```
|
||||
|
||||
- Or use a different path and set `GOLDEN_IMAGE` when installing the script (see below). On Proxmox with LXC, you can store backup images on a host directory by setting `CM4_BACKUPS_HOST_PATH` at deploy so that folder is bind-mounted into the LXC — see **PROXMOX-LXC-DEPLOYMENT.md** § Store backup images on a host directory.
|
||||
|
||||
#### 3. Install the provisioning script and trigger
|
||||
|
||||
```bash
|
||||
# From this repo (emmc-provisioning/host/)
|
||||
cd emmc-provisioning/host
|
||||
|
||||
sudo mkdir -p /opt/cm4-provisioning
|
||||
sudo cp flash-emmc-on-connect.sh /opt/cm4-provisioning/
|
||||
sudo chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
|
||||
# Optional: override paths via environment (create env file)
|
||||
echo 'GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img' | sudo tee /opt/cm4-provisioning/env
|
||||
echo 'RPIBOOT_DIR=/opt/usbboot' | sudo tee -a /opt/cm4-provisioning/env
|
||||
echo 'EMMC_SIZE_BYTES=8589934592' | sudo tee -a /opt/cm4-provisioning/env # 8GB; use 17179869184 for 16GB
|
||||
# Optional: shrink backups after dd (requires PiShrink; see "Shrinking backup and golden images" below)
|
||||
# echo 'SHRINK_BACKUP=1' | sudo tee -a /opt/cm4-provisioning/env
|
||||
# echo 'PISHRINK_COMPRESS=gz' | sudo tee -a /opt/cm4-provisioning/env # or xz
|
||||
|
||||
sudo cp cm4-flash-trigger.sh /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||
```
|
||||
|
||||
If your golden image path or rpiboot path is different, set `GOLDEN_IMAGE`, `RPIBOOT_DIR`, and optionally `EMMC_SIZE_BYTES` in `/opt/cm4-provisioning/env` and source it from the script, or pass them into the systemd-run call in the trigger (e.g. by making the trigger source the env file and export variables before `systemd-run`).
|
||||
|
||||
#### 4. Install udev rule
|
||||
|
||||
```bash
|
||||
# From emmc-provisioning/host/
|
||||
sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
#### 5. Enable provisioning (safety)
|
||||
|
||||
Provisioning runs only if the “enabled” file exists:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/cm4-provisioning
|
||||
sudo touch /etc/cm4-provisioning/enabled
|
||||
```
|
||||
|
||||
To disable provisioning (no device detection), remove that file: `sudo rm /etc/cm4-provisioning/enabled`.
|
||||
|
||||
#### 6. Optional: pass environment into the provisioning job
|
||||
|
||||
If you use `/opt/cm4-provisioning/env`, update the trigger so the flash script sees those variables. For example change `/usr/local/bin/cm4-flash-trigger.sh` to:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -a
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
set +a
|
||||
export GOLDEN_IMAGE RPIBOOT_DIR EMMC_SIZE_BYTES
|
||||
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
|
||||
exec systemd-run --no-block --unit=cm4-flash-once --property=Environment="GOLDEN_IMAGE=$GOLDEN_IMAGE" ...
|
||||
```
|
||||
|
||||
Or keep it simple and edit the defaults inside `flash-emmc-on-connect.sh` (e.g. `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`).
|
||||
|
||||
### Usage
|
||||
|
||||
1. Fit the **eMMC disable** jumper on the reTerminal.
|
||||
2. Connect the reTerminal **USB slave** port to the provisioning PC.
|
||||
3. Power the reTerminal (or apply power after USB).
|
||||
4. On the host, `rpiboot` will run automatically; when it exits, the script will `dd` the golden image to the eMMC. Watch logs: `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f`.
|
||||
5. When done, remove the jumper and power cycle the reTerminal. It will boot from eMMC; cloud-init will run on first boot.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Golden image with cloud-init
|
||||
|
||||
Raspberry Pi OS (recent versions) supports **cloud-init** using the **NoCloud** datasource: it reads `user-data`, `meta-data`, and optionally `network-config` from the **boot** (FAT32) partition.
|
||||
|
||||
### Steps to prepare a cloud-init image for Raspberry Pi OS
|
||||
|
||||
1. **Obtain Raspberry Pi OS**
|
||||
- Download from [raspberrypi.com/software](https://www.raspberrypi.com/software/) (desktop or Lite), or use **Raspberry Pi Imager** with "Edit settings" for locale/SSH. Recent Raspberry Pi OS has cloud-init built in.
|
||||
|
||||
2. **Flash the image**
|
||||
- Flash to a spare SD card, or to a loop file for building without physical media: `cp raspios.img golden-work.img`
|
||||
|
||||
3. **Mount the boot partition**
|
||||
- **From an image file**: `sudo losetup -fP golden-work.img` then e.g. `sudo mount /dev/loop0p1 /mnt/boot`
|
||||
- **From SD**: mount the first (FAT32) partition at `/mnt/boot`. On a running Pi, boot is often `/boot/firmware`.
|
||||
|
||||
4. **Add NoCloud files on the boot partition** (root of FAT32, same level as `config.txt`):
|
||||
- `user-data`, `meta-data`, `network-config`
|
||||
- From this repo: `cp emmc-provisioning/cloud-init/{user-data,meta-data,network-config} /mnt/boot/`
|
||||
|
||||
5. **Customise** `user-data` and `network-config`. Use the **remote bootstrap script** pattern (below) to avoid rebuilding the image when you change first-boot commands.
|
||||
|
||||
6. **Unmount and create golden image**: `sudo umount /mnt/boot`, then copy the image to your `GOLDEN_IMAGE` path (e.g. `/var/lib/cm4-provisioning/golden.img`).
|
||||
|
||||
### Remote bootstrap script (no image rebuild for script changes)
|
||||
|
||||
You can keep the golden image **fixed** and have cloud-init **download a script from a file server** and run it on first boot. When you change the script on the server, the next device gets the new commands without rebuilding the image.
|
||||
|
||||
1. **Host the script** on an HTTP/HTTPS file server (e.g. nginx, or `python3 -m http.server`) at a URL the Pi can reach, e.g. `http://192.168.1.10/provisioning/bootstrap.sh`.
|
||||
|
||||
2. **In `user-data`, use `runcmd` to download and run it** (runcmd runs after packages and network are up):
|
||||
|
||||
```yaml
|
||||
#cloud-config
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
packages:
|
||||
- curl # or wget
|
||||
|
||||
runcmd:
|
||||
- curl -fsSL "http://YOUR_FILE_SERVER/provisioning/bootstrap.sh" -o /tmp/bootstrap.sh
|
||||
- chmod +x /tmp/bootstrap.sh
|
||||
- /tmp/bootstrap.sh
|
||||
```
|
||||
|
||||
With **wget**: `wget -q -O /tmp/bootstrap.sh "http://.../bootstrap.sh"` then `chmod +x /tmp/bootstrap.sh` and `/tmp/bootstrap.sh`.
|
||||
|
||||
3. **Bootstrap script**: use a normal shell script (e.g. `#!/bin/bash` and `set -e`). It can install packages, configure kiosk, set hostname, register with a dashboard, etc.
|
||||
|
||||
4. **Notes**: Ensure `network-config` (or DHCP) gives the Pi an IP before runcmd. For HTTPS, add `ca-certificates` to `packages` if needed. You can use one script for all devices or have the script call your server with serial/MAC for device-specific config.
|
||||
|
||||
### Creating the golden image
|
||||
|
||||
1. **Flash Raspberry Pi OS** (or your base image) to a spare SD card or a loop file.
|
||||
2. **Mount the boot partition** (first partition, FAT32). On the image file it might be at an offset; use `losetup -P` or mount the SD’s partition.
|
||||
3. **Add cloud-init NoCloud files** on the boot partition (same level as `config.txt`, not in a subfolder for default NoCloud):
|
||||
- `user-data` – main config (packages, runcmd, etc.)
|
||||
- `meta-data` – optional (instance-id, local-hostname)
|
||||
- `network-config` – optional (network config in netplan format)
|
||||
|
||||
You can use the examples in this repo:
|
||||
|
||||
```bash
|
||||
# After mounting boot partition at e.g. /mnt/boot
|
||||
# (On Raspberry Pi OS, boot is often /boot/firmware on the running system, or the first FAT partition of the image)
|
||||
cp emmc-provisioning/cloud-init/user-data /mnt/boot/
|
||||
cp emmc-provisioning/cloud-init/meta-data /mnt/boot/
|
||||
cp emmc-provisioning/cloud-init/network-config /mnt/boot/
|
||||
```
|
||||
|
||||
4. **Customise** `user-data` and `network-config` (hostname, WiFi, packages, Chromium kiosk, or a single runcmd that downloads and runs a remote script—see "Remote bootstrap script" above).
|
||||
5. **Copy your kiosk/Chromium scripts** into the image rootfs only if you are not using a remote script; otherwise the remote script can pull what it needs.
|
||||
6. **Unmount**, then create a **golden image** from the SD or loop device (e.g. `dd` or copy of the whole block device). Use that as `golden.img` on the provisioning host. Optionally shrink it with PiShrink (see below) to save space and speed up deploy.
|
||||
|
||||
### Shrinking backup and golden images (PiShrink)
|
||||
|
||||
Raw full-disk backups and golden images are the full size of the eMMC (e.g. 32 GB). [PiShrink](https://github.com/Drewsif/PiShrink) shrinks the **last partition** (must be ext2/3/4) to its minimum size and truncates the image file; on first boot the rootfs can expand back to fill the device. This reduces backup/golden image size (often to a few GB) and improves transfer times.
|
||||
|
||||
**On the provisioning host:**
|
||||
|
||||
1. **Install PiShrink and dependencies** (run as root on the host, or via `ssh root@HOST 'bash -s' < scripts/install-pishrink-on-host.sh`):
|
||||
|
||||
```bash
|
||||
# From this repo (on the host or via ssh)
|
||||
bash emmc-provisioning/scripts/install-pishrink-on-host.sh
|
||||
```
|
||||
|
||||
This installs `parted`, `e2fsprogs`, `gzip`, `pigz`, `xz-utils` and downloads `pishrink.sh` to `/usr/local/bin/pishrink.sh`.
|
||||
|
||||
2. **Shrink backups automatically** after each Backup in the dashboard: add to `/opt/cm4-provisioning/env` and ensure the trigger sources it:
|
||||
|
||||
```bash
|
||||
echo 'SHRINK_BACKUP=1' | sudo tee -a /opt/cm4-provisioning/env
|
||||
# Optional: compress after shrinking (smaller file; must decompress before using as golden image)
|
||||
# echo 'PISHRINK_COMPRESS=gz' | sudo tee -a /opt/cm4-provisioning/env # good balance
|
||||
echo 'PISHRINK_COMPRESS=xz' | sudo tee -a /opt/cm4-provisioning/env # minimum size (slower)
|
||||
```
|
||||
|
||||
With `SHRINK_BACKUP=1`, once a backup finishes, the script runs PiShrink on the `.img` file. Use **`PISHRINK_COMPRESS=xz`** for **minimum size** (smallest file, slower); or **`gz`** for a good balance. The file becomes `.img.xz` or `.img.gz` and must be decompressed before deploy (e.g. `xz -dk backup.img.xz` then copy to `golden.img`).
|
||||
|
||||
3. **Shrink a golden image manually** (e.g. after building from Raspberry Pi OS):
|
||||
|
||||
```bash
|
||||
sudo pishrink.sh -n /path/to/large.img /var/lib/cm4-provisioning/golden.img
|
||||
```
|
||||
|
||||
`-n` disables update check. Omit the second argument to shrink in place. The shrunk image will expand the rootfs on first boot when deployed to eMMC.
|
||||
|
||||
**Notes:**
|
||||
|
||||
- PiShrink only shrinks the **last** partition; it must be ext2/3/4 (standard Raspberry Pi OS root is ext4).
|
||||
- Compressed backups (`.img.gz` / `.img.xz`) are for archival; to use as golden image, decompress first (e.g. `gunzip -k backup.img.gz` then copy to `golden.img`).
|
||||
- **Dashboard "Shrink" / "Compress"** buttons run PiShrink **on the host** (not in the LXC). The dashboard writes a request file; the host runs `run-shrink-on-host.sh` when `cm4-shrink.path` sees it. Ensure PiShrink is installed on the host (see above) and that deploy has installed `run-shrink-on-host.sh` and enabled `cm4-shrink.path`.
|
||||
|
||||
### Cloud-init file locations on the Pi
|
||||
|
||||
- **NoCloud**: Boot partition root – `user-data`, `meta-data`, `network-config`.
|
||||
- Some images expect them in a subfolder `cloud-init/` or on a separate vfat partition labeled `cidata`; check your OS docs. Standard Raspberry Pi OS NoCloud uses the boot partition root.
|
||||
|
||||
### Per-device config (optional)
|
||||
|
||||
NoCloud can also use a **seed** partition or **config drive**. For per-device hostname/settings you can:
|
||||
|
||||
- Use **meta-data** `instance-id` and `local-hostname` and generate different `meta-data` per device when imaging (e.g. script that writes `meta-data` before flashing), or
|
||||
- Use a first-boot script that calls a provisioning server (e.g. by serial number) and applies device-specific config; cloud-init can launch that script from `runcmd`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Step | Action |
|
||||
|------|--------|
|
||||
| 1 | Build `usbboot`, install `rpiboot` on provisioning host. |
|
||||
| 2 | Create golden image with cloud-init `user-data`, `meta-data`, `network-config` on boot partition. |
|
||||
| 3 | Install `flash-emmc-on-connect.sh`, `cm4-flash-trigger.sh`, and udev rule; set `GOLDEN_IMAGE` and enable file. |
|
||||
| 4 | Put reTerminal in boot mode (jumper), connect USB to host; image is written automatically. |
|
||||
| 5 | Remove jumper, power cycle; device boots from eMMC and cloud-init runs on first boot. |
|
||||
|
||||
This gives you automatic deployment of the golden image to eMMC when the reTerminal is in boot mode, plus first-boot configuration via cloud-init.
|
||||
97
emmc-provisioning/docs/NETWORK-BOOT-LXC.md
Normal file
97
emmc-provisioning/docs/NETWORK-BOOT-LXC.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Network boot on the provisioning LXC (eth1 = LAN, eth0 = WAN)
|
||||
|
||||
The provisioning LXC can provide **network boot** (PXE-style) and **internet access** to devices connected on **eth1**, while **eth0** is used as WAN for the LXC itself.
|
||||
|
||||
## Roles
|
||||
|
||||
| Interface | Role | Typical config |
|
||||
|-----------|------|-----------------|
|
||||
| **eth0** | WAN | DHCP or static; default route; internet for the LXC |
|
||||
| **eth1** | LAN (provisioning) | Static e.g. `10.20.50.1/24`; DHCP server + TFTP server; NAT so clients get internet via eth0 |
|
||||
|
||||
Devices plugged into the same network as **eth1** (e.g. reTerminals with network boot enabled) will:
|
||||
|
||||
1. Get an IP via **DHCP** (from the LXC on eth1).
|
||||
2. Get **TFTP** boot files (Raspberry Pi firmware: `start4.elf`, `fixup4.dat`, kernel, etc.) for network boot.
|
||||
3. Have **internet** via NAT through the LXC (eth0).
|
||||
|
||||
## What you need on the LXC
|
||||
|
||||
1. **DHCP server** on eth1 only (e.g. **dnsmasq**), handing out addresses in e.g. `10.20.50.100`–`10.20.50.200` and advertising the TFTP server (next-server = LXC’s eth1 IP).
|
||||
2. **TFTP server** (dnsmasq can provide this) with **TFTP root** containing Raspberry Pi 4 / CM4 boot files.
|
||||
3. **IP forwarding** and **NAT** (nftables or iptables) so traffic from `10.20.50.0/24` is masqueraded out **eth0**.
|
||||
|
||||
## One-time setup (inside the LXC)
|
||||
|
||||
From your machine, run the setup script **on the LXC** (replace with your LXC IP if different):
|
||||
|
||||
```bash
|
||||
# From the repo (script runs inside the LXC)
|
||||
./emmc-provisioning/scripts/setup-network-boot-on-lxc.sh root@10.130.60.141
|
||||
```
|
||||
|
||||
Or SSH into the LXC and run the script there:
|
||||
|
||||
```bash
|
||||
ssh root@10.130.60.141
|
||||
# Copy or rsync the emmc-provisioning tree into the container, then:
|
||||
bash /path/to/setup-network-boot-on-lxc.sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
- Install **dnsmasq** (DHCP + TFTP).
|
||||
- Configure dnsmasq to listen only on **eth1**, with a DHCP range and TFTP root.
|
||||
- Create `/srv/tftpboot` and **fetch Raspberry Pi 4 boot files from GitHub** (raspberrypi/firmware, `boot/` folder) if not already present.
|
||||
- Enable **IPv4 forwarding** and **NAT** (nftables) so clients on eth1 use eth0 for internet.
|
||||
- Enable and start the **dnsmasq** service.
|
||||
|
||||
## Proxmox: adding eth1 to the LXC
|
||||
|
||||
If you create the container by hand or want a second interface:
|
||||
|
||||
1. On the **Proxmox host**, add a second network device to the container, e.g.:
|
||||
```bash
|
||||
pct set <CTID> --net1 name=eth1,bridge=vmbr1,ip=10.20.50.1/24
|
||||
```
|
||||
Use the bridge that corresponds to the physical LAN where reTerminals are connected (e.g. `vmbr1` or a dedicated provisioning bridge).
|
||||
|
||||
2. Inside the LXC, ensure **eth1** has a static address (e.g. in `/etc/network/interfaces`):
|
||||
```
|
||||
auto eth1
|
||||
iface eth1 inet static
|
||||
address 10.20.50.1/24
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## After setup: reTerminal network boot
|
||||
|
||||
1. Set the reTerminal **boot order** to try network first (e.g. `BOOT_ORDER=0x21`; see cloud-init/first-boot).
|
||||
2. Connect the reTerminal to the **same network as the LXC’s eth1** (e.g. 10.20.50.0/24).
|
||||
3. Power on; it will get an IP via DHCP and load boot files via TFTP from the LXC.
|
||||
4. For **provisioning** (Backup/Deploy), the netboot environment must run **network-client/provisioning-client.sh** with `PROVISIONING_SERVER=http://10.20.50.1:5000` so it talks to the dashboard on the LXC.
|
||||
|
||||
## TFTP boot files (Raspberry Pi 4 / CM4)
|
||||
|
||||
The setup script **automatically downloads** the official Raspberry Pi firmware `boot/` folder from GitHub (https://github.com/raspberrypi/firmware) into `/srv/tftpboot` when `start4cd.elf` is missing. No manual copy is needed.
|
||||
|
||||
To refresh or populate TFTP without re-running the full setup:
|
||||
|
||||
```bash
|
||||
./emmc-provisioning/scripts/populate-tftpboot-from-git.sh root@<LXC-IP>
|
||||
```
|
||||
|
||||
(Remove `/srv/tftpboot/start4cd.elf` on the LXC first if you want a full re-fetch.)
|
||||
|
||||
The TFTP root contains e.g. `start4cd.elf`, `fixup4cd.dat`, `config.txt`, `cmdline.txt`, `kernel8.img`, and other boot files. For a custom kernel or initramfs (e.g. for provisioning), add or replace files in `/srv/tftpboot` and adjust `config.txt` / `cmdline.txt` as needed.
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Where | Purpose |
|
||||
|-------------|--------|--------|
|
||||
| eth0 | LXC | WAN; LXC’s internet |
|
||||
| eth1 | LXC | LAN; 10.20.50.1/24; DHCP + TFTP |
|
||||
| dnsmasq | LXC | DHCP (on eth1) + TFTP |
|
||||
| TFTP root | LXC | e.g. `/srv/tftpboot` with RPi boot files |
|
||||
| NAT | LXC | 10.20.50.0/24 → eth0 so LAN has internet |
|
||||
292
emmc-provisioning/docs/PORTAL_STYLING_GUIDE.md
Normal file
292
emmc-provisioning/docs/PORTAL_STYLING_GUIDE.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Portal Styling Guide (Template)
|
||||
|
||||
Use this document when building new portals so they match the visual and UX style of the reference portal (e.g. FreePBX / TM VOIP Extensions Portal). Replace placeholders like `[Portal Name]` with your portal’s name where relevant.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design philosophy
|
||||
|
||||
- **Dark theme:** Dark backgrounds with light text; no light-mode variant in this guide.
|
||||
- **Accent:** Single accent (teal/cyan gradient) for primary actions, links, and highlights.
|
||||
- **Clarity:** Clear hierarchy (cards, sections, labels), consistent spacing, readable typography.
|
||||
- **Consistency:** Same tokens, components, and patterns across all pages (login, app, modals).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design tokens (CSS variables)
|
||||
|
||||
Define these in `:root` (or in a shared CSS file) and use them everywhere instead of hard-coded colors.
|
||||
|
||||
### Colors
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|--------|--------|
|
||||
| `--bg-primary` | `#0a0e14` | Page background |
|
||||
| `--bg-secondary` | `#11151c` | Header, secondary surfaces |
|
||||
| `--bg-tertiary` | `#1a1f2b` | Inputs, table header, hover states |
|
||||
| `--bg-card` | `#151a24` | Cards, modals |
|
||||
| `--accent-primary` | `#00d4aa` | Primary accent (teal) |
|
||||
| `--accent-secondary` | `#00b894` | Accent variant, secondary accent |
|
||||
| `--accent-glow` | `rgba(0, 212, 170, 0.15)` | Focus rings, subtle highlights |
|
||||
| `--text-primary` | `#e6e8eb` | Main text |
|
||||
| `--text-secondary` | `#8b949e` | Labels, secondary text |
|
||||
| `--text-muted` | `#5c6370` | Placeholders, disabled, hints |
|
||||
| `--border-color` | `#2d333b` | Borders (cards, inputs, tables) |
|
||||
| `--danger` | `#ff6b6b` | Errors, delete, destructive actions |
|
||||
| `--danger-glow` | `rgba(255, 107, 107, 0.15)` | Danger focus/hover background |
|
||||
| `--warning` | `#ffd93d` | Warnings |
|
||||
| `--success` | `#00d4aa` | Success (can match accent) |
|
||||
| `--gradient-accent` | `linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%)` | Primary buttons, logo text |
|
||||
|
||||
### Example `:root` block
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-secondary: #11151c;
|
||||
--bg-tertiary: #1a1f2b;
|
||||
--bg-card: #151a24;
|
||||
--accent-primary: #00d4aa;
|
||||
--accent-secondary: #00b894;
|
||||
--accent-glow: rgba(0, 212, 170, 0.15);
|
||||
--text-primary: #e6e8eb;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #5c6370;
|
||||
--border-color: #2d333b;
|
||||
--danger: #ff6b6b;
|
||||
--danger-glow: rgba(255, 107, 107, 0.15);
|
||||
--warning: #ffd93d;
|
||||
--success: #00d4aa;
|
||||
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Typography
|
||||
|
||||
- **Body / UI font:** `'Outfit', -apple-system, BlinkMacSystemFont, sans-serif`
|
||||
- **Monospace (data, code, IDs):** `'JetBrains Mono', monospace`
|
||||
|
||||
Load from Google Fonts:
|
||||
|
||||
```html
|
||||
<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;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
- **Body:** `color: var(--text-primary);` `line-height: 1.6;`
|
||||
- **Labels:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional `text-transform: uppercase;` `letter-spacing: 0.5px;`
|
||||
- **Card/section titles:** `font-size: 1.1rem;` `font-weight: 600;`
|
||||
- **Table header:** `font-size: 0.75rem–0.8rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;` `color: var(--text-secondary);`
|
||||
- **Table body:** `font-size: 0.9rem;` monospace for IDs/codes
|
||||
|
||||
---
|
||||
|
||||
## 4. Page layout
|
||||
|
||||
### Global
|
||||
|
||||
- **Reset:** `* { margin: 0; padding: 0; box-sizing: border-box; }`
|
||||
- **Body:** `background: var(--bg-primary);` `color: var(--text-primary);` `min-height: 100vh;` `font-family: 'Outfit', ...`
|
||||
|
||||
### Background treatment (optional)
|
||||
|
||||
Subtle gradient overlay for depth:
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
```
|
||||
|
||||
### Header (fixed)
|
||||
|
||||
- **Container:** `background: var(--bg-secondary);` `border-bottom: 1px solid var(--border-color);` `position: fixed; top: 0; left: 0; right: 0; z-index: 1000;` optional `backdrop-filter: blur(10px);`
|
||||
- **Top row:** Logo left; status/user/actions right; `padding: 1rem 2rem;` `display: flex; align-items: center; justify-content: space-between;`
|
||||
- **Tabs row:** Under the top row; `background: var(--bg-tertiary);` `padding: 0.5rem 2rem;` `border-top: 1px solid var(--border-color);` horizontal flex, gap, overflow-x auto for small screens
|
||||
|
||||
### Main content
|
||||
|
||||
- **Container:** `max-width: 1400px;` `margin: 0 auto;` `padding: 2rem;` `padding-top: calc(2rem + 140px);` (offset for fixed header + tabs). On mobile reduce padding and increase top offset if header stacks.
|
||||
|
||||
---
|
||||
|
||||
## 5. Logo
|
||||
|
||||
- **Wrapper:** flex, `align-items: center;` `gap: 0.75rem;`
|
||||
- **Icon:** Square (e.g. 40×40px), `background: var(--gradient-accent);` `border-radius: 10px;` optional `box-shadow: 0 4px 20px var(--accent-glow);` emoji or icon inside.
|
||||
- **Title (h1):** `font-size: 1.5rem;` `font-weight: 600;` `background: var(--gradient-accent);` `-webkit-background-clip: text;` `background-clip: text;` `-webkit-text-fill-color: transparent;`
|
||||
|
||||
---
|
||||
|
||||
## 6. Tabs (main navigation)
|
||||
|
||||
- **Tab button (default):** `padding: 0.75rem 1.5rem;` `background: transparent;` `border: none;` `color: var(--text-secondary);` `font-size: 0.95rem;` `font-weight: 500;` `border-radius: 8px;` flex with icon + label, `gap: 0.5rem;`
|
||||
- **Hover:** `color: var(--text-primary);` `background: var(--bg-tertiary);`
|
||||
- **Active:** `background: var(--gradient-accent);` `color: var(--bg-primary);`
|
||||
- **Tab content:** `display: none;` by default; `.tab-content.active { display: block; }` optional fade-in animation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cards
|
||||
|
||||
- **Base:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 1.5rem;` `margin-bottom: 1.5rem;`
|
||||
- **Card header:** flex, `justify-content: space-between;` `align-items: center;` `margin-bottom: 1.5rem;` `padding-bottom: 1rem;` `border-bottom: 1px solid var(--border-color);`
|
||||
- **Card title:** `font-size: 1.1rem;` `font-weight: 600;` flex with icon + text, `gap: 0.5rem;`
|
||||
|
||||
---
|
||||
|
||||
## 8. Buttons
|
||||
|
||||
- **Base:** `padding: 0.75rem 1.5rem;` `border-radius: 8px;` `font-size: 0.9rem;` `font-weight: 500;` inline-flex, `align-items: center;` `gap: 0.5rem;` `transition: all 0.2s ease;`
|
||||
- **Primary:** `background: var(--gradient-accent);` `color: var(--bg-primary);` `border: none;` hover: slight `translateY(-2px);` `box-shadow: 0 4px 20px var(--accent-glow);`
|
||||
- **Secondary:** `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `color: var(--text-primary);` hover: `border-color: var(--accent-primary);`
|
||||
- **Danger:** `background: transparent;` `border: 1px solid var(--danger);` `color: var(--danger);` hover: `background: var(--danger);` `color: white;`
|
||||
- **Disabled:** `opacity: 0.5;` `cursor: not-allowed;`
|
||||
- **Icon-only (small):** e.g. 28×28px, `border-radius: 6px;` same semantic colors (e.g. `.btn-remove` with `--danger`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Forms
|
||||
|
||||
- **Grid:** `display: grid;` `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));` `gap: 1rem;`
|
||||
- **Form group:** flex column, `gap: 0.5rem;`
|
||||
- **Label:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional uppercase + letter-spacing
|
||||
- **Input / select:** `padding: 0.75rem 1rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 8px;` `color: var(--text-primary);` `font-size: 0.9rem;` monospace for IDs/codes
|
||||
- **Focus:** `outline: none;` `border-color: var(--accent-primary);` `box-shadow: 0 0 0 3px var(--accent-glow);`
|
||||
- **Placeholder:** `color: var(--text-muted);`
|
||||
- **Read-only:** `background: var(--bg-secondary);` `cursor: default;`
|
||||
- **Checkbox:** `accent-color: var(--accent-primary);` (or custom size e.g. 18×18px)
|
||||
- **Password field:** wrapper with toggle button; input `padding-right: 3rem;` so toggle doesn’t overlap text.
|
||||
|
||||
---
|
||||
|
||||
## 10. Tables
|
||||
|
||||
- **Container:** `overflow-x: auto;` `border-radius: 12px;` `border: 1px solid var(--border-color);`
|
||||
- **Table:** `width: 100%;` `border-collapse: collapse;`
|
||||
- **th / td:** `padding: 0.6rem 0.75rem;` `text-align: left;` `border-bottom: 1px solid var(--border-color);` `color: var(--text-primary);`
|
||||
- **th:** `background: var(--bg-tertiary);` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;`
|
||||
- **tbody tr hover:** `background-color: var(--bg-tertiary);`
|
||||
- **Last row:** `tr:last-child td { border-bottom: none; }`
|
||||
- **Data cells:** `font-family: 'JetBrains Mono', monospace;` `font-size: 0.9rem;`
|
||||
- **Actions column:** right-aligned; min-width for action buttons; `.table-actions { display: flex; gap: 1rem; flex-wrap: wrap; }`
|
||||
- **Data table variant:** `.data-table` with alternating row background (e.g. `nth-child(even)` subtle `rgba(255,255,255,0.02)`) and same hover.
|
||||
|
||||
---
|
||||
|
||||
## 11. Badges and status
|
||||
|
||||
- **Status badge (e.g. connection):** flex, `align-items: center;` `gap: 0.5rem;` `padding: 0.5rem 1rem;` `background: var(--bg-tertiary);` `border-radius: 20px;` `font-size: 0.85rem;` `border: 1px solid var(--border-color);`
|
||||
- **Status dot:** 8×8px circle; `.connected { background: var(--success); }` `.error { background: var(--danger); }` optional pulse animation
|
||||
- **Pill badge (e.g. extension ID):** `padding: 0.25rem 0.75rem;` `background: var(--accent-glow);` `color: var(--accent-primary);` `border-radius: 20px;` `font-weight: 500;`
|
||||
- **Tech badge (e.g. PJSIP/SIP):** small, `border-radius: 4px;` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` distinct colors per type (e.g. PJSIP blue, SIP purple)
|
||||
|
||||
---
|
||||
|
||||
## 12. Search and filters
|
||||
|
||||
- **Search box:** wrapper relative; input `padding: 0.6rem 1rem 0.6rem 2.5rem;` `width: 250px;` same colors as form inputs; optional `::before` search icon (e.g. 🔍) `left: 0.75rem;`
|
||||
- **Filter checkbox:** inline-flex, `align-items: center;` `gap: 0.4rem;` `color: var(--text-secondary);` `accent-color: var(--accent-primary);`
|
||||
|
||||
---
|
||||
|
||||
## 13. Empty and loading states
|
||||
|
||||
- **Empty state:** `text-align: center;` `padding: 3rem;` `color: var(--text-muted);`
|
||||
- **No results:** same idea, `padding: 2rem;`
|
||||
- **Loading:** flex center, `padding: 2rem;` spinner (e.g. 32×32px border, `border-top-color: var(--accent-primary);` `animation: spin 1s linear infinite;`)
|
||||
|
||||
---
|
||||
|
||||
## 14. Modals
|
||||
|
||||
- **Overlay:** `position: fixed;` `inset: 0;` `background: rgba(0,0,0,0.7);` `z-index: 1000;` flex center; `display: none;` `.active { display: flex; }`
|
||||
- **Dialog:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 2rem;` `max-width: 500px;` `width: 90%;` optional scale-in animation
|
||||
- **Modal header:** flex, `align-items: center;` `gap: 1rem;` `margin-bottom: 1.5rem;`
|
||||
- **Modal icon:** e.g. 48×48px, `background: var(--accent-glow);` `border-radius: 12px;` centered content
|
||||
- **Modal title:** `font-size: 1.25rem;` `font-weight: 600;`
|
||||
- **Detail sections:** `margin-bottom: 2rem;` section title `font-size: 1.1rem;` `color: var(--accent-primary);` `border-bottom: 1px solid var(--border-color);` `padding-bottom: 0.5rem;`
|
||||
- **Details grid:** `grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));` `gap: 1rem;` each item: label (small, secondary) + value (primary)
|
||||
|
||||
---
|
||||
|
||||
## 15. Toasts (notifications)
|
||||
|
||||
- **Container:** `position: fixed;` `top: 1rem;` `right: 1rem;` `z-index: 1100;` flex column, `gap: 0.5rem;`
|
||||
- **Toast:** `padding: 1rem 1.5rem;` `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 8px;` flex, `align-items: center;` `gap: 0.75rem;` `max-width: 400px;` slide-in animation
|
||||
- **Success:** `border-left: 3px solid var(--success);`
|
||||
- **Error:** `border-left: 3px solid var(--danger);`
|
||||
|
||||
---
|
||||
|
||||
## 16. Pagination
|
||||
|
||||
- **Wrapper:** flex, `justify-content: center;` `align-items: center;` `gap: 0.5rem;` `padding: 1rem;` `border-top: 1px solid var(--border-color);`
|
||||
- **Button:** `padding: 0.5rem 0.75rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 6px;` `color: var(--text-primary);` `font-size: 0.85rem;` `min-width: 36px;`
|
||||
- **Hover:** `background: var(--accent-glow);` `border-color: var(--accent-primary);` `color: var(--accent-primary);`
|
||||
- **Active page:** `background: var(--accent-primary);` `color: var(--bg-primary);`
|
||||
- **Disabled:** `opacity: 0.4;` `cursor: not-allowed;`
|
||||
- **Info text:** `color: var(--text-secondary);` `font-size: 0.85rem;` between prev/next
|
||||
|
||||
---
|
||||
|
||||
## 17. Login page
|
||||
|
||||
- **Layout:** full viewport, flex center; `padding: 2rem;`
|
||||
- **Card:** same tokens as app cards; `max-width: 400px;` `padding: 2.5rem;` `border-radius: 16px;` `box-shadow: 0 8px 32px rgba(0,0,0,0.3);`
|
||||
- **Logo:** centered; icon (e.g. 64×64px) with gradient + glow; title with gradient text
|
||||
- **Form:** same form-group and input styles as app; full-width primary submit button
|
||||
- **Error message:** `color: var(--danger);` `font-size: 0.9rem;` above or below form
|
||||
- Use the same `:root` variables and body background so login and app feel like one product.
|
||||
|
||||
---
|
||||
|
||||
## 18. Responsive
|
||||
|
||||
- **Breakpoint:** e.g. `@media (max-width: 768px)`
|
||||
- **Header top:** `flex-direction: column;` `gap: 1rem;` `text-align: center;` reduce padding
|
||||
- **Tabs:** reduce horizontal padding; allow horizontal scroll if needed
|
||||
- **Container:** reduce padding; increase `padding-top` if header height grows (e.g. `calc(1rem + 180px)`)
|
||||
- **Form grid:** `grid-template-columns: 1fr;` for single column on small screens
|
||||
|
||||
---
|
||||
|
||||
## 19. Checklist for a new portal
|
||||
|
||||
- [ ] Copy or recreate the `:root` design tokens.
|
||||
- [ ] Load **Outfit** and **JetBrains Mono** (or same weights).
|
||||
- [ ] Use the same header structure: logo + status/user + actions, then tabs.
|
||||
- [ ] Use `.card`, `.card-header`, `.card-title` for sections.
|
||||
- [ ] Use `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger` for actions.
|
||||
- [ ] Use `.form-grid`, `.form-group`, and input/select styles for forms.
|
||||
- [ ] Use `.table-container`, table, `.data-table` and th/td styles for lists.
|
||||
- [ ] Use same modal overlay/dialog and toast styles.
|
||||
- [ ] Use same empty, loading, and error states.
|
||||
- [ ] Apply same login page layout and token usage.
|
||||
- [ ] Test at 768px width for basic responsive behavior.
|
||||
- [ ] Replace [Portal Name] and any product-specific labels in this guide for your portal.
|
||||
|
||||
---
|
||||
|
||||
## 20. Reference files (this repo)
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| `static/css/main.css` | Full implementation of tokens, layout, components |
|
||||
| `app/templates/base.html` | App shell: fonts, header, tabs, container, toasts |
|
||||
| `app/templates/login.html` | Login layout and inline tokens (can be moved to main.css) |
|
||||
| `app/templates/tabs/_dashboard.html` | Example: cards, stats, table container |
|
||||
| `app/templates/tabs/_users.html` | Example: card, form-grid, form-group, buttons, table |
|
||||
|
||||
For a new portal, you can copy `main.css` and adapt it (e.g. change `:root` if you need a different accent), then build your base template and pages to use the same class names and structure described above.
|
||||
248
emmc-provisioning/docs/PROXMOX-LXC-DEPLOYMENT.md
Normal file
248
emmc-provisioning/docs/PROXMOX-LXC-DEPLOYMENT.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# CM4 eMMC provisioning on Proxmox (LXC + host)
|
||||
|
||||
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.
|
||||
|
||||
## One-command deploy
|
||||
|
||||
From your repo, a single run deploys **all** host and LXC files (scripts, systemd units, udev, dashboard):
|
||||
|
||||
```bash
|
||||
./emmc-provisioning/scripts/deploy-to-proxmox.sh root@YOUR_PROXMOX_HOST
|
||||
```
|
||||
|
||||
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>`).
|
||||
|
||||
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
|
||||
|
||||
| Where | What |
|
||||
|-------|-----|
|
||||
| **Proxmox host** | udev rule, trigger script, flash script, build-cloudinit and run-shrink scripts, systemd path units (build + shrink), `/var/lib/cm4-provisioning/`, `/etc/cm4-provisioning/enabled` |
|
||||
| **LXC (hostname cm4-provisioning)** | Dashboard (Flask) in `/opt/cm4-provisioning/dashboard/`; `/var/lib/cm4-provisioning/` is a **bind mount** from the host (shared storage for golden image and backups) |
|
||||
|
||||
When you plug the reTerminal in boot mode into the **host**, udev on the host runs the flash (rpiboot + dd). The golden image is read from `/var/lib/cm4-provisioning/golden.img` on the host (same path visible in the LXC).
|
||||
|
||||
---
|
||||
|
||||
## Deployment layout (after running the deploy script)
|
||||
|
||||
1. **LXC** (hostname `cm4-provisioning`, ID = found by hostname or next free):
|
||||
- Debian 12, 1 GB RAM, 8 GB rootfs
|
||||
- Bind mount: host `/var/lib/cm4-provisioning` → container `/var/lib/cm4-provisioning`
|
||||
- Optional second mount: `CM4_BACKUPS_HOST_PATH` → container `/var/lib/cm4-provisioning/backups`
|
||||
|
||||
2. **On the host**:
|
||||
- `/opt/cm4-provisioning/flash-emmc-on-connect.sh` – flash script
|
||||
- `/opt/cm4-provisioning/build-cloudinit-image.sh` – build cloud-init image (triggered by path unit)
|
||||
- `/opt/cm4-provisioning/run-shrink-on-host.sh` – PiShrink for dashboard Shrink/Compress
|
||||
- `/usr/local/bin/cm4-flash-trigger.sh` – started by udev
|
||||
- `/etc/udev/rules.d/90-cm4-boot-mode.rules` – run trigger when USB vendor `2b8e` is added
|
||||
- `/opt/cm4-provisioning/env` – `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES` (and `BACKUPS_DIR` if `CM4_BACKUPS_HOST_PATH` set)
|
||||
- `/etc/cm4-provisioning/enabled` – safety switch (remove to disable auto-flash)
|
||||
- systemd: `cm4-build-cloudinit.path` + `.service`, `cm4-shrink.path` + `.service`
|
||||
- `/opt/cm4-provisioning/fix-gadget-bootcode-on-host.sh` – used by `install-usbboot-on-host.sh` after building usbboot (fixes "rpiboot gadget empty" when gadget has broken symlinks)
|
||||
|
||||
3. **Inside the LXC** (use `pct exec <CTID> -- ...` where `<CTID>` is the ID of the container with hostname `cm4-provisioning`; get it with `pct list`):
|
||||
- Dashboard: Flask app in `/opt/cm4-provisioning/dashboard/` (monitor deployment, backup list, build cloud-init, set golden).
|
||||
- Golden image path: `/var/lib/cm4-provisioning/golden.img` (bind-mounted from host).
|
||||
|
||||
4. **usbboot (rpiboot)** is **not** installed by the deploy script. Install it when the host has internet (see below).
|
||||
|
||||
---
|
||||
|
||||
## What you need to do
|
||||
|
||||
### 1. Build and install rpiboot on the Proxmox host (when it has internet)
|
||||
|
||||
On your machine (repo already synced to the host):
|
||||
|
||||
```bash
|
||||
# From your repo
|
||||
scp emmc-provisioning/scripts/install-usbboot-on-host.sh root@10.130.60.224:/tmp/
|
||||
ssh root@10.130.60.224 "bash /tmp/install-usbboot-on-host.sh"
|
||||
```
|
||||
|
||||
Or on the host (if the deploy folder is still there):
|
||||
|
||||
```bash
|
||||
ssh root@10.130.60.224
|
||||
bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
|
||||
```
|
||||
|
||||
This installs dependencies, clones usbboot, builds it, and copies `rpiboot` to `/opt/usbboot/`. It then runs **fix-gadget-bootcode-on-host.sh** if present (from deploy), so the gadget has valid boot files and rpiboot does not fail with "No bootcode files found".
|
||||
|
||||
### 2. Enable root SSH and add your SSH key to the LXC
|
||||
|
||||
If you deployed with **`DEPLOY_LXC_ROOT_PASSWORD`** and/or a default SSH key (**`~/.ssh/id_ed25519.pub`** or **`id_rsa.pub`**), the LXC already has SSH enabled, root password set, and your key in `/root/.ssh/authorized_keys` — you can **skip** to `ssh root@<LXC-IP>` (get IP from deploy output or `pct exec <CTID> -- hostname -I`).
|
||||
|
||||
Otherwise, to enable root SSH and add a key:
|
||||
|
||||
- **Option A – Use the setup script (recommended):** From your machine (with SSH key and optional password):
|
||||
|
||||
```bash
|
||||
# Add your default SSH key (~/.ssh/id_ed25519.pub or id_rsa.pub) and enable root SSH
|
||||
./emmc-provisioning/scripts/setup-lxc-ssh.sh root@10.130.60.224
|
||||
|
||||
# Or specify key file and set root password
|
||||
ROOT_PASSWORD='YourPassword' ./emmc-provisioning/scripts/setup-lxc-ssh.sh root@10.130.60.224 ~/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
Then connect with `ssh root@<LXC-IP>` (script prints the IP). To get the LXC IP:
|
||||
`ssh root@HOST "CID=\$(pct list -no-header -output vmid,name | awk '\''\$2==\"cm4-provisioning\"{print \$1}'\''); pct exec \$CID -- hostname -I"`
|
||||
|
||||
- **Option B – Manual:**
|
||||
`ssh root@HOST`, then `pct exec <CTID> -- bash` (use the container ID from `pct list` for hostname cm4-provisioning). Install openssh-server, set `PermitRootLogin yes`, set root password, add your key, restart ssh.
|
||||
|
||||
### 3. (Optional) Store backup images on a host directory
|
||||
|
||||
To keep backup images on a specific host path (e.g. a large disk or NFS mount) instead of under `/var/lib/cm4-provisioning/backups`, deploy with **`CM4_BACKUPS_HOST_PATH`** set. That directory is created on the host, bind-mounted into the LXC at `/var/lib/cm4-provisioning/backups`, and the host flash script is configured to write backups there. The dashboard in the LXC then lists and serves those same files.
|
||||
|
||||
**Deploy with a host backup path:**
|
||||
|
||||
```bash
|
||||
CM4_BACKUPS_HOST_PATH=/mnt/storage/cm4-backups ./emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.130.60.224
|
||||
```
|
||||
|
||||
Create `/mnt/storage/cm4-backups` (or your path) on the host first if it doesn’t exist; the deploy script will create it if possible. To add or change the backup mount, set `CM4_BACKUPS_HOST_PATH` and run the deploy script again (it reuses the container by hostname and updates the bind mount).
|
||||
|
||||
### 4. Put the golden image on the host (or in the LXC)
|
||||
|
||||
The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**. Because that directory is bind-mounted into the LXC, you can use either:
|
||||
|
||||
- **From the host:**
|
||||
```bash
|
||||
scp your-golden.img root@10.130.60.224:/var/lib/cm4-provisioning/golden.img
|
||||
```
|
||||
|
||||
- **From the LXC** (e.g. after copying the image into the container elsewhere first):
|
||||
```bash
|
||||
pct exec <CTID> -- ls -la /var/lib/cm4-provisioning/
|
||||
# Replace <CTID> with the ID of the cm4-provisioning container (pct list).
|
||||
```
|
||||
|
||||
### 5. Run the provisioning dashboard (optional)
|
||||
|
||||
The dashboard shows **connection steps** and **live deployment status** (idle / connecting / flashing / done / error) and a recent flash log. It reads the same `status.json` and `flash.log` that the host’s flash script writes (via the bind-mounted `/var/lib/cm4-provisioning`).
|
||||
|
||||
**Inside the LXC (pct exec <CTID> -- bash):**
|
||||
|
||||
```bash
|
||||
# Copy dashboard into the container (from host, if you have the repo there)
|
||||
# Or from your workstation:
|
||||
# rsync -a emmc-provisioning/dashboard/ root@10.130.60.224:/tmp/dashboard/
|
||||
# Or re-run deploy-to-proxmox.sh to push the latest dashboard files.
|
||||
|
||||
# Inside the LXC (pct exec <CTID> -- bash):
|
||||
apt-get update && apt-get install -y python3-flask
|
||||
mkdir -p /opt/cm4-provisioning/dashboard/templates
|
||||
# Copy app.py, templates/index.html, cm4-dashboard.service into the container (see dashboard/README.md)
|
||||
|
||||
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cm4-dashboard
|
||||
```
|
||||
|
||||
Then open **http://<LXC-IP>:5000** (get the IP with `pct exec <CTID> -- hostname -I`). If the LXC is on a private network, set up port forwarding on the Proxmox host or use a reverse proxy.
|
||||
|
||||
### 6. Optional: disable or enable auto-flash
|
||||
|
||||
- **Disable:**
|
||||
`ssh root@10.130.60.224 "rm /etc/cm4-provisioning/enabled"`
|
||||
|
||||
- **Enable again:**
|
||||
`ssh root@10.130.60.224 "touch /etc/cm4-provisioning/enabled"`
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Place the reTerminal in **boot mode** (eMMC disable jumper).
|
||||
2. Connect its **USB slave** port to the **Proxmox host** (not to the LXC).
|
||||
3. Power the reTerminal (or connect after power).
|
||||
4. On the host, udev will run the trigger and then the flash script (rpiboot, then dd). Watch logs:
|
||||
```bash
|
||||
ssh root@10.130.60.224 "journalctl -u cm4-flash-once -f"
|
||||
# or
|
||||
ssh root@10.130.60.224 "journalctl -t cm4-flash -f"
|
||||
```
|
||||
5. When flashing finishes, remove the jumper and power cycle the reTerminal so it boots from eMMC.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring from the host
|
||||
|
||||
From the **Proxmox host** you can monitor:
|
||||
|
||||
| What | How |
|
||||
|------|-----|
|
||||
| **USB device** | `lsusb` — CM4 in boot mode shows as **2b8e** (RPi) or **0a5c:2711** (Broadcom BCM2711) |
|
||||
| **Live status** | `cat /var/lib/cm4-provisioning/status.json` — same JSON the dashboard shows (phase, message, error) |
|
||||
| **Flash log** | `tail -f /var/lib/cm4-provisioning/flash.log` — script log (rpiboot, dd, errors) |
|
||||
| **Flash job** | `systemctl status cm4-flash-once` — whether the udev-triggered job is running/failed |
|
||||
| **Journal** | `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f` — systemd/log output |
|
||||
| **Block devices** | `lsblk` — after rpiboot, the eMMC appears as a new disk (e.g. `/dev/sdb`) |
|
||||
| **Backups** | `ls /var/lib/cm4-provisioning/backups/` — backup images (on host; if you used `CM4_BACKUPS_HOST_PATH` they are under that path on the host, bind-mounted into the LXC). To shrink automatically, set `SHRINK_BACKUP=1` in `/opt/cm4-provisioning/env` — see **EMMC-PROVISIONING-GUIDE.md** § Shrinking backup and golden images. |
|
||||
| **Config** | `cat /opt/cm4-provisioning/env` — GOLDEN_IMAGE, RPIBOOT_DIR, EMMC_SIZE_BYTES |
|
||||
|
||||
**One-command snapshot:**
|
||||
|
||||
```bash
|
||||
# From your machine (stream script to host):
|
||||
ssh root@10.130.60.224 'bash -s' < emmc-provisioning/scripts/monitor-from-host.sh
|
||||
```
|
||||
|
||||
Or copy `scripts/monitor-from-host.sh` to the host and run `./monitor-from-host.sh` for a full status dump (USB, status.json, flash unit, last log lines, block devices, config).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting: device connected but not shown in portal
|
||||
|
||||
1. **Host has old flash script** – The script must *not* exit when the golden image is missing (so you can use Backup first). Update the host:
|
||||
```bash
|
||||
scp emmc-provisioning/host/flash-emmc-on-connect.sh root@10.130.60.224:/opt/cm4-provisioning/
|
||||
ssh root@10.130.60.224 "chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh"
|
||||
```
|
||||
|
||||
2. **Unplug and replug the USB** – udev runs the trigger only when the device is *added*. Unplug the reTerminal USB (keep it in boot mode), then plug it back in. The trigger will run the script and rpiboot; when the eMMC is exposed, the portal shows "Device connected" with Backup/Deploy.
|
||||
|
||||
3. **If rpiboot fails** – Check on the host: `ssh root@10.130.60.224 'tail -30 /var/lib/cm4-provisioning/flash.log'` (rpiboot stderr is appended there). Try unplug/replug again. To see the exact rpiboot error: `ssh root@10.130.60.224 '/opt/usbboot/rpiboot -d /opt/usbboot/mass-storage-gadget64'` (device connected; Ctrl+C to stop). Run `scripts/monitor-from-host.sh` for a full snapshot.
|
||||
|
||||
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 doesn’t 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.
|
||||
|
||||
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`).
|
||||
|
||||
6. **"Download failed" when building cloud-init image** – The download runs on the **host** (not the LXC). Check: (1) Host can reach the internet: `ssh root@HOST 'curl -sI https://downloads.raspberrypi.com/'`. (2) Build status now shows curl’s error (e.g. "Could not resolve host", "Connection timed out"); check the dashboard error text. (3) If you use a proxy or custom CA, set in `/opt/cm4-provisioning/env`: `CURL_INSECURE=1` to skip SSL verify (only if you understand the risk), then rerun the build.
|
||||
|
||||
7. **Backup stops before finishing** – If backup or shrink appears to stop partway (e.g. dashboard stuck on "Creating backup…" or "Shrinking…"), the service may have been killed by systemd. The `cm4-flash.service` unit uses `TimeoutStartSec=7200` (2 hours); if you deployed an older version with 15 minutes, redeploy so the host gets the updated unit, then on the host run `systemctl daemon-reload` so the next backup has enough time to complete.
|
||||
|
||||
8. **Trigger now runs the flash script in the background** (not via systemd-run) so it can access the USB device; a 2s delay gives the device time to enumerate before rpiboot runs.
|
||||
|
||||
---
|
||||
|
||||
## Redeploy / update scripts
|
||||
|
||||
From your repo (e.g. after changing scripts):
|
||||
|
||||
```bash
|
||||
./emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.130.60.224
|
||||
```
|
||||
|
||||
That script syncs the repo to the host and reinstalls scripts on both the host and LXC 201. It does **not** overwrite `/opt/cm4-provisioning/env` or `/etc/cm4-provisioning/enabled` if you’ve changed them; adjust the script if you want that. It also does **not** build usbboot; run `install-usbboot-on-host.sh` on the host when needed.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Item | Location |
|
||||
|------|----------|
|
||||
| LXC | Hostname `cm4-provisioning` (ID from `pct list`), on your Proxmox host |
|
||||
| Golden image | `/var/lib/cm4-provisioning/golden.img` (host and LXC see the same file) |
|
||||
| Flash runs on | Proxmox **host** (udev + rpiboot + dd) |
|
||||
| Build rpiboot on host | Run `scripts/install-usbboot-on-host.sh` on the host when it has internet |
|
||||
| Dashboard | Flask app in LXC at `http://<LXC-IP>:5000`; switch Flash/Backup mode, list and download backups; see **dashboard/README.md** and section 3 above |
|
||||
| Backups | Saved under `/var/lib/cm4-provisioning/backups/` (optionally a host path bind-mounted into the LXC — set `CM4_BACKUPS_HOST_PATH` at deploy). When a device is detected, choose **Backup** or **Deploy** in the dashboard. |
|
||||
| Network deploy/backup | Network-booted devices run **network-client/provisioning-client.sh** and register with the dashboard; they then appear under "Device detected (Network)" and you choose Backup or Deploy. See **network-client/README.md**. |
|
||||
| Network boot (DHCP + TFTP on eth1) | If the LXC has a second interface **eth1** as provisioning LAN (e.g. 10.20.50.1/24), run **scripts/setup-network-boot-on-lxc.sh root@<LXC-IP>** to install dnsmasq (DHCP+TFTP) on eth1 and NAT so LAN clients get internet via eth0. See **docs/NETWORK-BOOT-LXC.md**. |
|
||||
@@ -0,0 +1,4 @@
|
||||
# Ensure CM4 boot-mode USB device is accessible to rpiboot (libusb).
|
||||
# Load before 90-cm4-boot-mode.rules so permissions are set before the trigger runs.
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="2b8e", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0a5c", ATTR{idProduct}=="2711", MODE="0666"
|
||||
10
emmc-provisioning/host/90-cm4-boot-mode.rules
Normal file
10
emmc-provisioning/host/90-cm4-boot-mode.rules
Normal file
@@ -0,0 +1,10 @@
|
||||
# When reTerminal (CM4) is connected in USB boot mode (eMMC disable jumper),
|
||||
# the device appears as either Raspberry Pi Foundation (2b8e) or Broadcom BCM2711 Boot (0a5c:2711).
|
||||
# Trigger provisioning: run rpiboot, then wait for user to choose Backup or Deploy in the portal.
|
||||
# Install: sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
|
||||
# sudo udevadm control --reload-rules
|
||||
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="2b8e", ACTION=="add", \
|
||||
RUN+="/usr/local/bin/cm4-flash-trigger.sh"
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0a5c", ATTR{idProduct}=="2711", ACTION=="add", \
|
||||
RUN+="/usr/local/bin/cm4-flash-trigger.sh"
|
||||
11
emmc-provisioning/host/README.md
Normal file
11
emmc-provisioning/host/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Host-side provisioning scripts
|
||||
|
||||
These files run on the **provisioning host** (e.g. the Proxmox host where the reTerminal is connected via USB).
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| **flash-emmc-on-connect.sh** | Runs `rpiboot`, detects eMMC, waits for dashboard choice (Backup/Deploy), then runs `dd`. Install to `/opt/cm4-provisioning/`. |
|
||||
| **cm4-flash-trigger.sh** | Started by udev when USB device 2b8e is added. Launches the flash script via `systemd-run`. Install to `/usr/local/bin/`. |
|
||||
| **90-cm4-boot-mode.rules** | udev rule: on USB add (vendor 2b8e), run the trigger. Install to `/etc/udev/rules.d/`. |
|
||||
|
||||
See [../docs/EMMC-PROVISIONING-GUIDE.md](../docs/EMMC-PROVISIONING-GUIDE.md) for full setup. The [deploy script](../scripts/deploy-to-proxmox.sh) copies these into place on the Proxmox host and LXC.
|
||||
174
emmc-provisioning/host/build-cloudinit-image.sh
Normal file
174
emmc-provisioning/host/build-cloudinit-image.sh
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox host when build_cloudinit_request.json appears in the provisioning dir.
|
||||
# Downloads Raspberry Pi OS image from URL, injects cloud-init NoCloud files, saves to BACKUPS_DIR.
|
||||
# Uses loop devices and mount (available on host, not in unprivileged LXC).
|
||||
# Triggered by systemd path unit cm4-build-cloudinit.path.
|
||||
|
||||
set -e
|
||||
PROV_DIR="${CM4_PROVISIONING_DIR:-/var/lib/cm4-provisioning}"
|
||||
REQUEST_FILE="$PROV_DIR/build_cloudinit_request.json"
|
||||
STATUS_FILE="$PROV_DIR/build_cloudinit_status.json"
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
BACKUPS_DIR="${BACKUPS_DIR:-$PROV_DIR/backups}"
|
||||
CLOUDINIT_IMAGES_DIR="${CLOUDINIT_IMAGES_DIR:-$PROV_DIR/cloudinit-images}"
|
||||
DOWNLOAD_CACHE_DIR="${CM4_DOWNLOAD_CACHE_DIR:-$PROV_DIR/download-cache}"
|
||||
# Write built cloud-init images to CLOUDINIT_IMAGES_DIR (separate from backups)
|
||||
OUTPUT_DIR="${CLOUDINIT_IMAGES_DIR}"
|
||||
GOLDEN_IMAGE="${GOLDEN_IMAGE:-$PROV_DIR/golden.img}"
|
||||
|
||||
write_status() {
|
||||
local phase="$1" message="$2" output_name="$3" error="$4"
|
||||
printf '{"phase":"%s","message":"%s","output_name":%s,"error":%s,"updated":%s}\n' \
|
||||
"$phase" "$message" \
|
||||
"$([ -n "$output_name" ] && echo "\"$output_name\"" || echo "null")" \
|
||||
"$([ -n "$error" ] && echo "\"${error//\"/\\\"}\"" || echo "null")" \
|
||||
"$(date +%s)" > "$STATUS_FILE"
|
||||
}
|
||||
|
||||
[[ -f "$REQUEST_FILE" ]] || { echo "No request file"; exit 0; }
|
||||
|
||||
# Use temp dir on provisioning dir (not /tmp) so we have enough space for decompress (~3GB+)
|
||||
mkdir -p "$PROV_DIR"
|
||||
TEMP_DIR=$(mktemp -d -p "$PROV_DIR" build.XXXXXX 2>/dev/null) || TEMP_DIR="$PROV_DIR/build.$$" && mkdir -p "$TEMP_DIR"
|
||||
trap 'rm -rf "$TEMP_DIR"; rm -f "$REQUEST_FILE"' EXIT
|
||||
|
||||
# Extract fields from JSON into temp files (handles multi-line content)
|
||||
python3 - "$REQUEST_FILE" "$TEMP_DIR" << 'PY'
|
||||
import json, sys
|
||||
with open(sys.argv[1]) as f:
|
||||
d = json.load(f)
|
||||
out = sys.argv[2]
|
||||
for name, key in [("user-data", "user_data"), ("meta-data", "meta_data"), ("network-config", "network_config")]:
|
||||
with open(f"{out}/{name}", "w") as f:
|
||||
f.write(d.get(key, ""))
|
||||
with open(f"{out}/variant", "w") as f:
|
||||
f.write(d.get("variant", "lite"))
|
||||
with open(f"{out}/url", "w") as f:
|
||||
f.write(d.get("url", ""))
|
||||
with open(f"{out}/set_golden", "w") as f:
|
||||
f.write("1" if d.get("set_as_golden_after") else "0")
|
||||
PY
|
||||
|
||||
URL=$(cat "$TEMP_DIR/url")
|
||||
VARIANT=$(cat "$TEMP_DIR/variant")
|
||||
SET_AS_GOLDEN=$(cat "$TEMP_DIR/set_golden")
|
||||
[[ -n "$URL" ]] || { write_status "error" "" "" "Missing url in request"; exit 1; }
|
||||
|
||||
OUT_NAME="raspios-${VARIANT}-cloudinit-$(date +%Y%m%d-%H%M%S).img"
|
||||
OUT_PATH="$OUTPUT_DIR/$OUT_NAME"
|
||||
|
||||
BASENAME=$(basename "$URL")
|
||||
SHA256_URL="${URL}.sha256"
|
||||
CACHED="$DOWNLOAD_CACHE_DIR/$BASENAME"
|
||||
XZ_FILE="$TEMP_DIR/image.img.xz"
|
||||
CURL_ERR="$TEMP_DIR/curl_err.txt"
|
||||
SHA256_FILE="$TEMP_DIR/expected.sha256"
|
||||
|
||||
# Fetch official SHA256 checksum if available (Raspberry Pi OS publishes .img.xz.sha256 next to each image)
|
||||
write_status "downloading" "Checking for existing image and checksum…" "" ""
|
||||
EXPECTED_HASH=""
|
||||
if curl -sfL -o "$SHA256_FILE" "$SHA256_URL" 2>/dev/null && [[ -s "$SHA256_FILE" ]]; then
|
||||
# Format is typically "hash filename" or just "hash"; take first 64-char hex token
|
||||
EXPECTED_HASH=$(head -1 "$SHA256_FILE" | awk '{print $1}')
|
||||
[[ ${#EXPECTED_HASH} -eq 64 && "$EXPECTED_HASH" =~ ^[0-9a-fA-F]+$ ]] || EXPECTED_HASH=""
|
||||
fi
|
||||
|
||||
USE_CACHED=0
|
||||
mkdir -p "$DOWNLOAD_CACHE_DIR"
|
||||
if [[ -f "$CACHED" && -s "$CACHED" ]]; then
|
||||
if [[ -n "$EXPECTED_HASH" ]]; then
|
||||
ACTUAL_HASH=$(sha256sum -b "$CACHED" 2>/dev/null | awk '{print $1}')
|
||||
if [[ "$ACTUAL_HASH" == "$EXPECTED_HASH" ]]; then
|
||||
USE_CACHED=1
|
||||
else
|
||||
rm -f "$CACHED"
|
||||
fi
|
||||
else
|
||||
USE_CACHED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $USE_CACHED -eq 1 ]]; then
|
||||
write_status "downloading" "Using cached image (checksum OK): $BASENAME" "" ""
|
||||
cp "$CACHED" "$XZ_FILE"
|
||||
else
|
||||
write_status "downloading" "Downloading $(basename "$URL")…" "" ""
|
||||
CURL_OPTS=(-f -L -o "$XZ_FILE" --connect-timeout 30 --max-time 7200)
|
||||
[[ "${CURL_INSECURE:-}" == "1" ]] && CURL_OPTS+=(-k)
|
||||
if ! curl "${CURL_OPTS[@]}" "$URL" 2>"$CURL_ERR"; then
|
||||
err_detail=$(head -c 200 "$CURL_ERR" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
write_status "error" "" "" "Download failed: ${err_detail:-curl exited with error}"
|
||||
exit 1
|
||||
fi
|
||||
# Verify with official checksum if we have it
|
||||
if [[ -n "$EXPECTED_HASH" ]]; then
|
||||
ACTUAL_HASH=$(sha256sum -b "$XZ_FILE" 2>/dev/null | awk '{print $1}')
|
||||
if [[ "$ACTUAL_HASH" != "$EXPECTED_HASH" ]]; then
|
||||
write_status "error" "" "" "Checksum mismatch: download may be corrupted (expected ${EXPECTED_HASH:0:16}..., got ${ACTUAL_HASH:0:16}...). Try again."
|
||||
exit 1
|
||||
fi
|
||||
write_status "downloading" "Checksum OK, caching image…" "" ""
|
||||
fi
|
||||
cp "$XZ_FILE" "$CACHED"
|
||||
fi
|
||||
|
||||
write_status "decompressing" "Decompressing image…" "" ""
|
||||
# Check we have a real xz file (not HTML error page)
|
||||
if ! command -v xz >/dev/null 2>&1; then
|
||||
write_status "error" "" "" "Decompress failed: xz not installed. Install xz-utils (apt install xz-utils)"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$XZ_FILE" ]]; then
|
||||
write_status "error" "" "" "Decompress failed: downloaded file is empty"
|
||||
exit 1
|
||||
fi
|
||||
FILE_TYPE=$(file -b "$XZ_FILE" 2>/dev/null || true)
|
||||
if [[ "$FILE_TYPE" == *"HTML"* ]] || [[ "$FILE_TYPE" == *"text"* ]] && [[ "$FILE_TYPE" != *"XZ"* ]]; then
|
||||
write_status "error" "" "" "Decompress failed: download is not an image (got: ${FILE_TYPE:0:80})"
|
||||
exit 1
|
||||
fi
|
||||
# Need ~3GB+ free for decompressing typical Raspios .img.xz; use same filesystem as TEMP_DIR
|
||||
AVAIL_KB=$(df -P "$TEMP_DIR" 2>/dev/null | awk 'NR==2 {print $4}')
|
||||
AVAIL_GB=0
|
||||
[[ -n "$AVAIL_KB" ]] && AVAIL_GB=$((AVAIL_KB / 1024 / 1024))
|
||||
if [[ -n "$AVAIL_KB" && "$AVAIL_KB" -lt 3145728 ]]; then
|
||||
write_status "error" "" "" "Decompress failed: not enough disk space (${AVAIL_GB}GB free on $(df -P "$TEMP_DIR" 2>/dev/null | awk 'NR==2 {print $6}'); need ~3GB). Free space or use a larger volume."
|
||||
exit 1
|
||||
fi
|
||||
IMG_FILE="$TEMP_DIR/image.img"
|
||||
XZ_ERR="$TEMP_DIR/xz_err.txt"
|
||||
# -T 1: single-threaded to reduce memory use; -d -k -f: decompress, keep source, force
|
||||
if ! xz -T 1 -d -k -f "$XZ_FILE" 2>"$XZ_ERR"; then
|
||||
err_detail=$(cat "$XZ_ERR" 2>/dev/null | tr '\n' ' ' | sed 's/"/\\"/g' | head -c 400)
|
||||
[[ -z "$err_detail" ]] && err_detail="out of disk space or memory?"
|
||||
write_status "error" "" "" "Decompress failed: ${err_detail} (free space: ${AVAIL_GB}GB)"
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$IMG_FILE" ]] || { write_status "error" "" "" "image.img not found after decompress"; exit 1; }
|
||||
|
||||
write_status "injecting" "Mounting boot partition and injecting cloud-init…" "" ""
|
||||
LOOP=$(losetup -f --show -P "$IMG_FILE")
|
||||
boot_part="${LOOP}p1"
|
||||
[[ -b "$boot_part" ]] || boot_part="${LOOP}p2"
|
||||
[[ -b "$boot_part" ]] || { write_status "error" "" "" "Boot partition not found"; losetup -d "$LOOP"; exit 1; }
|
||||
|
||||
MNT="$TEMP_DIR/mnt"
|
||||
mkdir -p "$MNT"
|
||||
mount "$boot_part" "$MNT"
|
||||
cp "$TEMP_DIR/user-data" "$MNT/user-data"
|
||||
cp "$TEMP_DIR/meta-data" "$MNT/meta-data"
|
||||
cp "$TEMP_DIR/network-config" "$MNT/network-config"
|
||||
umount "$MNT"
|
||||
losetup -d "$LOOP"
|
||||
|
||||
write_status "finalizing" "Copying image to cloud-init images…" "" ""
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
cp "$IMG_FILE" "$OUT_PATH"
|
||||
|
||||
if [[ "$SET_AS_GOLDEN" == "1" ]]; then
|
||||
rm -f "$GOLDEN_IMAGE"
|
||||
ln -sf "$OUT_PATH" "$GOLDEN_IMAGE"
|
||||
write_status "done" "Built $OUT_NAME and set as golden image." "$OUT_NAME" ""
|
||||
else
|
||||
write_status "done" "Built $OUT_NAME" "$OUT_NAME" ""
|
||||
fi
|
||||
9
emmc-provisioning/host/cm4-build-cloudinit.path
Normal file
9
emmc-provisioning/host/cm4-build-cloudinit.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=CM4 build cloud-init image (when request file appears)
|
||||
After=local-fs.target
|
||||
|
||||
[Path]
|
||||
PathExists=/var/lib/cm4-provisioning/build_cloudinit_request.json
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
14
emmc-provisioning/host/cm4-build-cloudinit.service
Normal file
14
emmc-provisioning/host/cm4-build-cloudinit.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=CM4 build cloud-init image (download Raspios, inject NoCloud)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=CM4_PROVISIONING_DIR=/var/lib/cm4-provisioning
|
||||
ExecStart=/opt/cm4-provisioning/build-cloudinit-image.sh
|
||||
# Run as root; script uses losetup, mount
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
TimeoutStartSec=7200
|
||||
7
emmc-provisioning/host/cm4-flash-trigger.sh
Normal file
7
emmc-provisioning/host/cm4-flash-trigger.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Called by udev when CM4 in boot mode (0a5c:2711 or 2b8e) is connected.
|
||||
# Start the flash service via systemd so it runs under systemd, not udev —
|
||||
# otherwise udev kills the process when the device re-enumerates (first→second stage).
|
||||
# --no-block: return immediately so udev doesn't wait; the service runs in the background.
|
||||
|
||||
systemctl --no-block start cm4-flash.service
|
||||
21
emmc-provisioning/host/cm4-flash.service
Normal file
21
emmc-provisioning/host/cm4-flash.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=CM4 eMMC provisioning (rpiboot + backup/deploy)
|
||||
# Run after udev has settled; do not block boot
|
||||
After=systemd-udevd.service
|
||||
DefaultDependencies=yes
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Delay so USB device is enumerated and udev permissions applied before we run
|
||||
ExecStartPre=/bin/sleep 5
|
||||
ExecStart=/opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
# Run as root; flash script logs to /var/lib/cm4-provisioning/flash.log
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Allow long run: rpiboot + wait for choice + dd 32GB (~20–35 min) + optional PiShrink (~15–30 min)
|
||||
TimeoutStartSec=7200
|
||||
|
||||
[Install]
|
||||
# Only started by udev trigger, not at boot
|
||||
WantedBy=multi-user.target
|
||||
9
emmc-provisioning/host/cm4-shrink.path
Normal file
9
emmc-provisioning/host/cm4-shrink.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=CM4 shrink/compress backup (when request file appears)
|
||||
After=local-fs.target
|
||||
|
||||
[Path]
|
||||
PathExists=/var/lib/cm4-provisioning/shrink_request.json
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
12
emmc-provisioning/host/cm4-shrink.service
Normal file
12
emmc-provisioning/host/cm4-shrink.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=CM4 run PiShrink on requested backup image
|
||||
After=local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=CM4_PROVISIONING_DIR=/var/lib/cm4-provisioning
|
||||
ExecStart=/opt/cm4-provisioning/run-shrink-on-host.sh
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
TimeoutStartSec=3600
|
||||
233
emmc-provisioning/host/flash-emmc-on-connect.sh
Normal file
233
emmc-provisioning/host/flash-emmc-on-connect.sh
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
# Provision CM4 eMMC when reTerminal is connected in boot mode (eMMC disable jumper).
|
||||
# On USB boot device (2b8e) detection: run rpiboot to expose eMMC, then wait for the user
|
||||
# to choose Backup or Deploy in the portal — no auto-flash; action runs only after portal choice.
|
||||
# Run this from udev or a systemd service. Requires: usbboot (rpiboot) built, golden image for deploy.
|
||||
|
||||
# Do NOT use set -e: rpiboot and intermediate commands may return non-zero without being fatal
|
||||
|
||||
# Redirect all output to log file (stdout + stderr) so nothing is lost to nohup buffering
|
||||
LOG_FILE="${LOG_FILE:-/var/lib/cm4-provisioning/flash.log}"
|
||||
exec >> "$LOG_FILE" 2>&1
|
||||
|
||||
# Load overrides from env file if present
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
|
||||
# Configuration - adjust paths and size for your setup
|
||||
RPIBOOT_DIR="${RPIBOOT_DIR:-/opt/usbboot}"
|
||||
GOLDEN_IMAGE="${GOLDEN_IMAGE:-/var/lib/cm4-provisioning/golden.img}"
|
||||
# Expected eMMC size in bytes. reTerminal DM (CM4) has 32 GB eMMC (~31268536320 bytes).
|
||||
EMMC_SIZE_BYTES="${EMMC_SIZE_BYTES:-$(( 32 * 1024 * 1024 * 1024 ))}"
|
||||
LOG_TAG="cm4-flash"
|
||||
STATUS_FILE="${STATUS_FILE:-/var/lib/cm4-provisioning/status.json}"
|
||||
LOG_FILE="${LOG_FILE:-/var/lib/cm4-provisioning/flash.log}"
|
||||
LOCK_FILE="${LOCK_FILE:-/var/lib/cm4-provisioning/flash.lock}"
|
||||
|
||||
log() { echo "[$(date -Iseconds)] [$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
|
||||
|
||||
write_status() {
|
||||
local phase="$1" message="$2" progress="${3:-null}" error="${4:-}"
|
||||
local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
message="${message//\"/\\\"}"; error="${error//\"/\\\"}"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '{"phase":"%s","message":"%s","progress":%s,"error":"%s","updated":"%s"}\n' \
|
||||
"$phase" "$message" "$progress" "$error" "$ts" > "$STATUS_FILE" 2>/dev/null || true
|
||||
else
|
||||
printf '{"phase":"%s","message":"%s","progress":%s,"updated":"%s"}\n' \
|
||||
"$phase" "$message" "$progress" "$ts" > "$STATUS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Prevent concurrent runs (udev fires multiple times during rpiboot re-enumeration)
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "[$(date -Iseconds)] Another flash-emmc instance is already running; exiting."
|
||||
exit 0
|
||||
fi
|
||||
trap 'rm -f "$LOCK_FILE" "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null; exec 9>&-' EXIT
|
||||
|
||||
# Optional: only run if this file exists (safety)
|
||||
ENABLE_FILE="${ENABLE_FILE:-/etc/cm4-provisioning/enabled}"
|
||||
if [[ -n "$ENABLE_FILE" && ! -f "$ENABLE_FILE" ]]; then
|
||||
log "Skipping: $ENABLE_FILE not present"
|
||||
write_status "idle" "Provisioning disabled (remove /etc/cm4-provisioning/enabled to enable)" "null" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# When a device is detected we ask the user (dashboard): Backup or Deploy?
|
||||
# These files are used to wait for the user's choice (written by dashboard, read by this script).
|
||||
BACKUPS_DIR="${BACKUPS_DIR:-/var/lib/cm4-provisioning/backups}"
|
||||
# Optional: shrink backup with PiShrink (requires pishrink + parted, e2fsprogs). SHRINK_BACKUP=1 to enable.
|
||||
SHRINK_BACKUP="${SHRINK_BACKUP:-0}"
|
||||
# If SHRINK_BACKUP=1, optionally compress: PISHRINK_COMPRESS=gz or xz (uses parallel when available). Uncompressed .img can be dd'd directly for deploy.
|
||||
PISHRINK_COMPRESS="${PISHRINK_COMPRESS:-}"
|
||||
ACTION_REQUEST_FILE="${ACTION_REQUEST_FILE:-/var/lib/cm4-provisioning/action_request}"
|
||||
CURRENT_DEVICE_FILE="${CURRENT_DEVICE_FILE:-/var/lib/cm4-provisioning/current_device}"
|
||||
DEVICE_SOURCE_FILE="${DEVICE_SOURCE_FILE:-/var/lib/cm4-provisioning/device_source}"
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-600}"
|
||||
# Golden image required only for Deploy; allow Backup without it
|
||||
if [[ ! -f "$GOLDEN_IMAGE" ]]; then
|
||||
log "Golden image not found (Deploy will be unavailable): $GOLDEN_IMAGE"
|
||||
fi
|
||||
|
||||
RPIBOOT_BIN="$RPIBOOT_DIR/rpiboot"
|
||||
# Gadget dir for CM4 (64-bit); fallback to mass-storage-gadget
|
||||
RPIBOOT_GADGET=""
|
||||
for d in "$RPIBOOT_DIR/mass-storage-gadget64" "$RPIBOOT_DIR/mass-storage-gadget"; do
|
||||
if [[ -d "$d" ]]; then
|
||||
RPIBOOT_GADGET="$d"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ ! -x "$RPIBOOT_BIN" ]]; then
|
||||
log "rpiboot not found: $RPIBOOT_BIN (build usbboot and set RPIBOOT_DIR)"
|
||||
write_status "error" "rpiboot not installed" "null" "rpiboot not found. Run install-usbboot-on-host.sh on the host or build-and-deploy-usbboot-to-host.sh from your machine."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$RPIBOOT_GADGET" ]]; then
|
||||
log "rpiboot gadget dir not found under $RPIBOOT_DIR (need mass-storage-gadget64 or mass-storage-gadget)"
|
||||
write_status "error" "rpiboot gadget missing" "null" "Copy mass-storage-gadget(64) to $RPIBOOT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
# rpiboot requires bootfiles.bin or one of bootcode*.bin in the gadget dir; empty dir causes "No 'bootcode' files found"
|
||||
if [[ ! -f "$RPIBOOT_GADGET/bootfiles.bin" && ! -f "$RPIBOOT_GADGET/bootcode.bin" && ! -f "$RPIBOOT_GADGET/bootcode4.bin" && ! -f "$RPIBOOT_GADGET/bootcode5.bin" ]]; then
|
||||
log "rpiboot gadget dir has no boot files: $RPIBOOT_GADGET (reinstall usbboot)"
|
||||
write_status "error" "rpiboot gadget empty" "null" "No boot files in $RPIBOOT_GADGET. On the host run: fix-gadget-bootcode-on-host.sh (or from your machine: ssh root@HOST 'bash -s' < scripts/fix-gadget-bootcode-on-host.sh). See docs troubleshooting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure status dir exists and start with running state
|
||||
mkdir -p "$(dirname "$STATUS_FILE")" "$BACKUPS_DIR" 2>/dev/null || true
|
||||
write_status "rpiboot" "Connecting to CM4 in boot mode…" "0"
|
||||
|
||||
# Block devices before rpiboot (so we can detect new one after)
|
||||
before_devs=$(lsblk -nd -o NAME 2>/dev/null | sort)
|
||||
|
||||
log "Starting rpiboot to expose CM4 eMMC as mass storage..."
|
||||
# Run rpiboot with 90s timeout so we don't hang if it doesn't exit cleanly when device switches to mass storage
|
||||
rpiboot_exit=0
|
||||
timeout 90 "$RPIBOOT_BIN" -d "$RPIBOOT_GADGET" || rpiboot_exit=$?
|
||||
# timeout returns 124 if killed by timeout; 0 or other if rpiboot exited on its own
|
||||
if [[ "$rpiboot_exit" -eq 124 ]]; then
|
||||
log "rpiboot timed out after 90s (device may have switched to mass storage)"
|
||||
elif [[ "$rpiboot_exit" -ne 0 ]]; then
|
||||
log "rpiboot exited with code $rpiboot_exit"
|
||||
write_status "error" "rpiboot failed" "null" "rpiboot failed or no device connected. Check flash.log on host. Try unplug/replug USB."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$(date -Iseconds)] rpiboot finished (exit=$rpiboot_exit); starting device scan"
|
||||
log "rpiboot completed; waiting for block device..."
|
||||
write_status "rpiboot" "rpiboot done, waiting for block device…" "10"
|
||||
|
||||
# rpiboot exits when device switches to mass storage; udev may need several seconds to create /dev/sdX
|
||||
# Poll for new block device for up to 30s (device switch can be slow)
|
||||
target_dev=""
|
||||
for wait_sec in $(seq 2 2 10) $(seq 12 2 30); do
|
||||
sleep 2
|
||||
for dev in /dev/sd[a-z] /dev/sd[a-z][a-z]; do
|
||||
[[ -b "$dev" ]] || continue
|
||||
[[ "$dev" =~ [0-9]$ ]] && continue
|
||||
size=$(blockdev --getsize64 "$dev" 2>/dev/null || true)
|
||||
if [[ -n "$size" ]]; then
|
||||
if (( size >= EMMC_SIZE_BYTES * 95 / 100 && size <= EMMC_SIZE_BYTES * 105 / 100 )); then
|
||||
target_dev=$dev
|
||||
break 2
|
||||
fi
|
||||
if [[ -z "$target_dev" && "$before_devs" != *"${dev#/dev/}"* ]]; then
|
||||
target_dev=$dev
|
||||
fi
|
||||
fi
|
||||
done
|
||||
[[ -n "$target_dev" ]] && break
|
||||
log "Waiting for block device... ${wait_sec}s"
|
||||
write_status "rpiboot" "Waiting for eMMC block device… (${wait_sec}s)" "10"
|
||||
done
|
||||
|
||||
log "Device scan complete. before_devs=[$before_devs] target_dev=[$target_dev]"
|
||||
if [[ -z "$target_dev" ]]; then
|
||||
log "No suitable block device found after rpiboot (expected ~${EMMC_SIZE_BYTES} bytes)"
|
||||
write_status "error" "No eMMC device found" "null" "No suitable block device after rpiboot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask user (dashboard): Backup or Deploy?
|
||||
write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard." "null"
|
||||
echo "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true
|
||||
echo "$target_dev" > "$CURRENT_DEVICE_FILE" 2>/dev/null || true
|
||||
log "Waiting for user choice (Backup or Deploy) in dashboard; timeout ${WAIT_TIMEOUT}s..."
|
||||
for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
|
||||
sleep 2
|
||||
if [[ -f "$ACTION_REQUEST_FILE" ]]; then
|
||||
action=$(cat "$ACTION_REQUEST_FILE" 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
rm -f "$ACTION_REQUEST_FILE" 2>/dev/null || true
|
||||
if [[ "$action" == "backup" ]]; then
|
||||
backup_name="backup-$(date +%Y%m%d-%H%M%S).img"
|
||||
backup_path="$BACKUPS_DIR/$backup_name"
|
||||
write_status "backup" "Creating backup…" "null"
|
||||
log "Backing up $target_dev to $backup_path..."
|
||||
if dd if="$target_dev" of="$backup_path" bs=4M status=progress conv=fsync; then
|
||||
final_name="$backup_name"
|
||||
shrink_requested=false
|
||||
[[ "$SHRINK_BACKUP" == "1" || "$SHRINK_BACKUP" == "true" ]] && shrink_requested=true
|
||||
[[ -f "$(dirname "$STATUS_FILE")/shrink_next_backup" ]] && shrink_requested=true
|
||||
rm -f "$(dirname "$STATUS_FILE")/shrink_next_backup" 2>/dev/null || true
|
||||
if [[ "$shrink_requested" == "true" ]]; then
|
||||
pishrink_cmd=""
|
||||
for p in /usr/local/bin/pishrink.sh /usr/local/bin/pishrink; do
|
||||
[[ -x "$p" ]] && pishrink_cmd="$p" && break
|
||||
done
|
||||
if [[ -n "$pishrink_cmd" ]]; then
|
||||
write_status "backup" "Shrinking backup image…" "null"
|
||||
log "Shrinking backup with PiShrink (timeout 30 min)..."
|
||||
pishrink_opts="-n"
|
||||
[[ "$PISHRINK_COMPRESS" == "gz" || "$PISHRINK_COMPRESS" == "gzip" ]] && pishrink_opts="$pishrink_opts -z -a"
|
||||
[[ "$PISHRINK_COMPRESS" == "xz" ]] && pishrink_opts="$pishrink_opts -Z -a"
|
||||
PISHRINK_TIMEOUT="${PISHRINK_TIMEOUT:-1800}"
|
||||
if timeout "$PISHRINK_TIMEOUT" $pishrink_cmd $pishrink_opts "$backup_path" 2>&1; then
|
||||
if [[ "$PISHRINK_COMPRESS" == "gz" || "$PISHRINK_COMPRESS" == "gzip" ]]; then
|
||||
final_name="${backup_name}.gz"
|
||||
elif [[ "$PISHRINK_COMPRESS" == "xz" ]]; then
|
||||
final_name="${backup_name}.xz"
|
||||
fi
|
||||
log "Shrunk backup: $BACKUPS_DIR/$final_name"
|
||||
else
|
||||
rc=$?
|
||||
if [[ "$rc" -eq 124 ]]; then
|
||||
log "PiShrink timed out after ${PISHRINK_TIMEOUT}s; keeping full backup $backup_path"
|
||||
else
|
||||
log "PiShrink failed (exit $rc); keeping full backup $backup_path"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "SHRINK_BACKUP=1 but pishrink not found; keeping full backup. Install with scripts/install-pishrink-on-host.sh"
|
||||
fi
|
||||
fi
|
||||
log "Backup complete: $BACKUPS_DIR/$final_name"
|
||||
write_status "done" "Backup complete: $final_name" "100"
|
||||
else
|
||||
write_status "error" "Backup failed" "null" "dd failed"
|
||||
fi
|
||||
elif [[ "$action" == "deploy" ]]; then
|
||||
if [[ ! -f "$GOLDEN_IMAGE" ]]; then
|
||||
log "Golden image not found; cannot deploy."
|
||||
write_status "error" "Deploy unavailable" "null" "Golden image not found. Add golden.img to /var/lib/cm4-provisioning/ for deploy."
|
||||
exit 1
|
||||
fi
|
||||
write_status "flashing" "Writing golden image…" "null"
|
||||
log "Flashing $GOLDEN_IMAGE to $target_dev..."
|
||||
if dd if="$GOLDEN_IMAGE" of="$target_dev" bs=4M status=progress conv=fsync; then
|
||||
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"
|
||||
else
|
||||
write_status "error" "Flash failed" "null" "dd failed"
|
||||
fi
|
||||
else
|
||||
write_status "error" "Unknown action" "null" "action_request must be 'backup' or 'deploy'"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
log "Timeout waiting for user choice"
|
||||
write_status "idle" "Timeout waiting for choice. Connect the device again to retry." "null"
|
||||
exit 0
|
||||
77
emmc-provisioning/host/run-shrink-on-host.sh
Normal file
77
emmc-provisioning/host/run-shrink-on-host.sh
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox host when shrink_request.json appears in the provisioning dir.
|
||||
# Runs PiShrink on the requested backup image (shrink or shrink+compress).
|
||||
# Requires PiShrink on host (scripts/install-pishrink-on-host.sh). Triggered by cm4-shrink.path.
|
||||
|
||||
set -e
|
||||
PROV_DIR="${CM4_PROVISIONING_DIR:-/var/lib/cm4-provisioning}"
|
||||
REQUEST_FILE="$PROV_DIR/shrink_request.json"
|
||||
STATUS_FILE="$PROV_DIR/shrink_status.json"
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
BACKUPS_DIR="${BACKUPS_DIR:-$PROV_DIR/backups}"
|
||||
PISHRINK="${PISHRINK_SCRIPT:-/usr/local/bin/pishrink.sh}"
|
||||
SHRINK_TIMEOUT="${SHRINK_TIMEOUT:-2100}"
|
||||
|
||||
write_status() {
|
||||
local phase="$1" name="$2" message="$3" error="$4"
|
||||
printf '{"phase":"%s","name":"%s","message":"%s","error":%s,"updated":%s}\n' \
|
||||
"$phase" "$name" "$message" \
|
||||
"$([ -n "$error" ] && echo "\"${error//\"/\\\"}\"" || echo "null")" \
|
||||
"$(date +%s)" > "$STATUS_FILE"
|
||||
}
|
||||
|
||||
[[ -f "$REQUEST_FILE" ]] || { echo "No request file"; exit 0; }
|
||||
|
||||
# Parse request
|
||||
NAME=$(python3 -c "import json; print(json.load(open('$REQUEST_FILE')).get('name',''))")
|
||||
ACTION=$(python3 -c "import json; print(json.load(open('$REQUEST_FILE')).get('action','shrink'))")
|
||||
FORMAT=$(python3 -c "import json; print(json.load(open('$REQUEST_FILE')).get('format','xz'))")
|
||||
rm -f "$REQUEST_FILE"
|
||||
|
||||
# Validate
|
||||
if [[ -z "$NAME" ]]; then
|
||||
write_status "error" "" "" "Missing name in request"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$NAME" != "${NAME##*/}" ]] || [[ "$NAME" == *..* ]]; then
|
||||
write_status "error" "$NAME" "" "Invalid name"
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! "$NAME" =~ \.img$ ]]; then
|
||||
write_status "error" "$NAME" "" "Only .img files can be shrunk"
|
||||
exit 0
|
||||
fi
|
||||
PATH_FILE="$BACKUPS_DIR/$NAME"
|
||||
if [[ ! -f "$PATH_FILE" ]]; then
|
||||
write_status "error" "$NAME" "" "File not found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PISHRINK" ]]; then
|
||||
write_status "error" "$NAME" "" "PiShrink not installed on host. Run scripts/install-pishrink-on-host.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
write_status "running" "$NAME" "Shrinking…" ""
|
||||
OPTS=(-n)
|
||||
if [[ "$ACTION" == "compress" ]]; then
|
||||
if [[ "$FORMAT" == "gz" ]] || [[ "$FORMAT" == "gzip" ]]; then
|
||||
OPTS+=(-z -a)
|
||||
else
|
||||
OPTS+=(-Z -a)
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$BACKUPS_DIR" || exit 1
|
||||
if timeout "$SHRINK_TIMEOUT" "$PISHRINK" "${OPTS[@]}" "$NAME" 2>&1; then
|
||||
if [[ "$ACTION" == "compress" ]]; then
|
||||
ext=".xz"
|
||||
[[ "$FORMAT" == "gz" ]] || [[ "$FORMAT" == "gzip" ]] && ext=".gz"
|
||||
write_status "done" "$NAME" "Compressed to ${NAME}${ext}" ""
|
||||
else
|
||||
write_status "done" "$NAME" "Shrunk $NAME" ""
|
||||
fi
|
||||
else
|
||||
rc=$?
|
||||
write_status "error" "$NAME" "" "PiShrink failed (exit $rc)"
|
||||
fi
|
||||
16
emmc-provisioning/lxc/README.md
Normal file
16
emmc-provisioning/lxc/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# LXC config (network boot: eth1 = LAN, eth0 = WAN)
|
||||
|
||||
Config files for the **provisioning LXC** when using **eth1** as a provisioning LAN (DHCP + TFTP for network boot, NAT for internet).
|
||||
|
||||
| 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`. |
|
||||
|
||||
Setup is done by running (from your machine):
|
||||
|
||||
```bash
|
||||
./emmc-provisioning/scripts/setup-network-boot-on-lxc.sh root@<LXC-IP>
|
||||
```
|
||||
|
||||
See [../docs/NETWORK-BOOT-LXC.md](../docs/NETWORK-BOOT-LXC.md) for full documentation.
|
||||
26
emmc-provisioning/lxc/dnsmasq-network-boot.conf
Normal file
26
emmc-provisioning/lxc/dnsmasq-network-boot.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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.
|
||||
|
||||
# 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=10.20.50.100,10.20.50.200,12h
|
||||
|
||||
# TFTP for Raspberry Pi / CM4 network boot
|
||||
enable-tftp
|
||||
tftp-root=/srv/tftpboot
|
||||
|
||||
# RPi 4 netboot: next-server is this host; boot filename (Pi firmware uses this)
|
||||
# Option 66 = next-server (TFTP), 67 = boot filename
|
||||
dhcp-option=66,10.20.50.1
|
||||
dhcp-option=67,start4cd.elf
|
||||
|
||||
# Logging (optional; disable in production if too noisy)
|
||||
log-dhcp
|
||||
log-queries
|
||||
|
||||
# Do not use /etc/resolv.conf or act as DNS if you only want DHCP+TFTP
|
||||
port=0
|
||||
10
emmc-provisioning/lxc/nft-nat-lan.conf
Normal file
10
emmc-provisioning/lxc/nft-nat-lan.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
# 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).
|
||||
|
||||
table ip nat {
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.20.50.0/24 oifname "eth0" masquerade
|
||||
}
|
||||
}
|
||||
27
emmc-provisioning/network-client/README.md
Normal file
27
emmc-provisioning/network-client/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Network provisioning client
|
||||
|
||||
When a reTerminal (or any Pi) has **network boot** enabled and boots over the network, it can register with the provisioning dashboard and then perform **Deploy** (pull golden image and write to eMMC) or **Backup** (read eMMC and upload to the server) when you choose the action in the dashboard.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Device boots (e.g. from NFS or a minimal netboot root).
|
||||
2. Run **provisioning-client.sh** with `PROVISIONING_SERVER` set to the dashboard URL (e.g. `http://<LXC-IP>:5000`).
|
||||
3. The script registers (MAC + IP) and polls `GET /api/device-action-poll?mac=...`.
|
||||
4. In the dashboard, the device appears under "Device detected (Network)". You click **Backup** or **Deploy**.
|
||||
5. The device gets the action from the next poll: **Deploy** → it downloads `GET /api/golden-image` and runs `dd of=/dev/mmcblk0`. **Backup** → it runs `dd if=/dev/mmcblk0` and POSTs to `POST /api/backup-upload?mac=...`.
|
||||
|
||||
## Usage on the device
|
||||
|
||||
```bash
|
||||
export PROVISIONING_SERVER=http://192.168.1.10:5000 # dashboard URL
|
||||
./provisioning-client.sh
|
||||
```
|
||||
|
||||
- **EMMC_DEV**: override eMMC block device (default `/dev/mmcblk0`).
|
||||
|
||||
The device must have network access to the dashboard and (for deploy) the dashboard must have `golden.img` in its provisioning directory.
|
||||
|
||||
## Integrating into a netboot environment
|
||||
|
||||
- Add this script to your netboot root (e.g. NFS-mounted filesystem or initramfs).
|
||||
- Run it from a first-boot or default login script so that when the device boots from the network it registers and waits for an action. Optionally run it as a systemd service that starts after network is up.
|
||||
49
emmc-provisioning/network-client/provisioning-client.sh
Normal file
49
emmc-provisioning/network-client/provisioning-client.sh
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run this script on a reTerminal that has booted over the network (or any Pi with network).
|
||||
# It registers with the provisioning dashboard and waits for Backup or Deploy, then performs the action.
|
||||
# Usage: PROVISIONING_SERVER=http://192.168.1.10:5000 ./provisioning-client.sh
|
||||
# Requires: curl, and for deploy/backup: enough space and write access to eMMC (e.g. /dev/mmcblk0).
|
||||
|
||||
set -e
|
||||
BASE_URL="${PROVISIONING_SERVER:-http://192.168.1.10:5000}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
EMMC_DEV="${EMMC_DEV:-/dev/mmcblk0}"
|
||||
MAC=$(cat /sys/class/net/eth0/address 2>/dev/null || cat /sys/class/net/enp*/address 2>/dev/null | head -1 || echo "unknown")
|
||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
|
||||
|
||||
echo "Registering with $BASE_URL (MAC=$MAC IP=$IP)..."
|
||||
curl -s -X POST -H "Content-Type: application/json" -d "{\"mac\":\"$MAC\",\"ip\":\"$IP\"}" "$BASE_URL/api/register-device" || true
|
||||
|
||||
echo "Polling for action (Backup or Deploy)..."
|
||||
while true; do
|
||||
resp=$(curl -s "$BASE_URL/api/device-action-poll?mac=$MAC" 2>/dev/null || echo '{"action":"wait"}')
|
||||
action=$(echo "$resp" | grep -o '"action":"[^"]*"' | cut -d'"' -f4)
|
||||
url=$(echo "$resp" | grep -o '"url":"[^"]*"' | cut -d'"' -f4)
|
||||
upload_url=$(echo "$resp" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [[ "$action" == "deploy" && -n "$url" ]]; then
|
||||
echo "Deploy: downloading image and writing to $EMMC_DEV..."
|
||||
if [[ ! -b "$EMMC_DEV" ]]; then
|
||||
echo "Error: $EMMC_DEV not found"
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
curl -sL "$url" | dd of="$EMMC_DEV" bs=4M status=progress conv=fsync
|
||||
echo "Deploy done. Reboot to run from eMMC."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$action" == "backup" && -n "$upload_url" ]]; then
|
||||
echo "Backup: reading $EMMC_DEV and uploading..."
|
||||
if [[ ! -b "$EMMC_DEV" ]]; then
|
||||
echo "Error: $EMMC_DEV not found"
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
dd if="$EMMC_DEV" bs=4M status=progress 2>/dev/null | curl -s -X POST -T - "$upload_url"
|
||||
echo "Backup done."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
56
emmc-provisioning/scripts/build-and-deploy-usbboot-to-host.sh
Executable file
56
emmc-provisioning/scripts/build-and-deploy-usbboot-to-host.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build usbboot (rpiboot) on THIS machine (e.g. Fedora) and deploy to the Proxmox host.
|
||||
# Use this when the host has no internet. Requires: dnf (Fedora) or apt (Debian/Ubuntu), git, ssh to host.
|
||||
# Usage: ./build-and-deploy-usbboot-to-host.sh [proxmox_host]
|
||||
# Example: ./build-and-deploy-usbboot-to-host.sh root@10.130.60.224
|
||||
|
||||
set -e
|
||||
PROXMOX="${1:-root@10.130.60.224}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="/tmp/usbboot-build-$$"
|
||||
cleanup() { rm -rf "$BUILD_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$(date -Iseconds)] Building usbboot for host $PROXMOX ..."
|
||||
|
||||
# Install build deps (Fedora or Debian/Ubuntu)
|
||||
if command -v dnf &>/dev/null; then
|
||||
echo "Installing build deps (dnf)..."
|
||||
sudo dnf install -y git libusb1-devel pkg-config glibc-devel gcc gcc-c++ make
|
||||
elif command -v apt-get &>/dev/null; then
|
||||
echo "Installing build deps (apt)..."
|
||||
sudo apt-get update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git libusb-1.0-0-dev pkg-config build-essential
|
||||
else
|
||||
echo "Error: need dnf or apt-get to install dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
cd "$BUILD_DIR"
|
||||
echo "Cloning usbboot (with submodules)..."
|
||||
git clone --recurse-submodules --shallow-submodules --depth=1 https://github.com/raspberrypi/usbboot
|
||||
cd usbboot
|
||||
echo "Building..."
|
||||
make
|
||||
|
||||
# Deploy: binary + gadget dir(s) to host /opt/usbboot
|
||||
echo "[$(date -Iseconds)] Deploying to $PROXMOX:/opt/usbboot ..."
|
||||
ssh "$PROXMOX" "mkdir -p /opt/usbboot"
|
||||
rsync -a "$BUILD_DIR/usbboot/rpiboot" "$PROXMOX:/opt/usbboot/"
|
||||
# CM4 needs mass-storage-gadget (64-bit); copy whichever exists
|
||||
for dir in mass-storage-gadget64 mass-storage-gadget; do
|
||||
if [[ -d "$BUILD_DIR/usbboot/$dir" ]]; then
|
||||
rsync -a "$BUILD_DIR/usbboot/$dir/" "$PROXMOX:/opt/usbboot/$dir/"
|
||||
echo " Copied $dir/"
|
||||
fi
|
||||
done
|
||||
ssh "$PROXMOX" "chmod +x /opt/usbboot/rpiboot"
|
||||
|
||||
# Verify gadget has boot files (rpiboot needs bootfiles.bin or bootcode*.bin)
|
||||
if ! ssh "$PROXMOX" "test -f /opt/usbboot/mass-storage-gadget64/bootfiles.bin || test -f /opt/usbboot/mass-storage-gadget64/boot.img" 2>/dev/null; then
|
||||
echo "Warning: mass-storage-gadget64 may be missing boot files. If rpiboot fails with 'No bootcode files found', run: ./populate-gadget-on-host.sh $PROXMOX"
|
||||
fi
|
||||
|
||||
echo "[$(date -Iseconds)] usbboot deployed to $PROXMOX:/opt/usbboot"
|
||||
echo "Ensure the host flash script runs rpiboot with: -d /opt/usbboot/mass-storage-gadget64 (or mass-storage-gadget)."
|
||||
21
emmc-provisioning/scripts/check-usb-on-host.sh
Normal file
21
emmc-provisioning/scripts/check-usb-on-host.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox HOST (where USB is connected) to verify the reTerminal in boot mode is seen.
|
||||
# Usage: ssh root@10.130.60.224 'bash -s' < scripts/check-usb-on-host.sh
|
||||
# Or copy to host and run: ./check-usb-on-host.sh
|
||||
|
||||
echo "=== CM4 boot-mode USB (2b8e = RPi, 0a5c:2711 = Broadcom BCM2711) ==="
|
||||
lsusb | grep -E "2b8e|0a5c" || echo "None found. Connect reTerminal in boot mode (eMMC disable jumper) and use the USB slave port."
|
||||
echo ""
|
||||
echo "=== All USB devices ==="
|
||||
lsusb
|
||||
echo ""
|
||||
echo "=== Provisioning status ==="
|
||||
cat /var/lib/cm4-provisioning/status.json 2>/dev/null || echo "No status.json (script has not run yet)."
|
||||
echo ""
|
||||
echo "=== Last flash log ==="
|
||||
tail -15 /var/lib/cm4-provisioning/flash.log 2>/dev/null || echo "No flash.log"
|
||||
echo ""
|
||||
echo "=== udev rule and rpiboot ==="
|
||||
test -f /etc/udev/rules.d/90-cm4-boot-mode.rules && echo "90-cm4-boot-mode.rules: present" || echo "90-cm4-boot-mode.rules: MISSING"
|
||||
test -x /opt/usbboot/rpiboot && echo "rpiboot: present" || echo "rpiboot: MISSING"
|
||||
test -f /etc/cm4-provisioning/enabled && echo "enabled: yes" || echo "enabled: no (provisioning disabled)"
|
||||
24
emmc-provisioning/scripts/deploy-20260218-095749.log
Normal file
24
emmc-provisioning/scripts/deploy-20260218-095749.log
Normal file
@@ -0,0 +1,24 @@
|
||||
[2026-02-18T09:57:49+02:00] Logging to /home/nearxos/Projects/reTerminal DM4/chromium-setup/emmc-provisioning/scripts/deploy-20260218-095749.log
|
||||
[2026-02-18T09:57:49+02:00] Deploying to root@10.130.60.224 ...
|
||||
[2026-02-18T09:57:49+02:00] [1/4] Cleaning remote staging dir ...
|
||||
[2026-02-18T09:57:50+02:00] [2/4] Rsync repo to root@10.130.60.224 ...
|
||||
[2026-02-18T09:57:50+02:00] [3/4] Running remote install (host + LXC) ...
|
||||
[2026-02-18T08:01:21+00:00] LXC 201 already exists.
|
||||
[2026-02-18T08:01:21+00:00] Host: installing scripts and udev ...
|
||||
[2026-02-18T08:01:22+00:00] Host: env and dirs ...
|
||||
[2026-02-18T08:01:22+00:00] Starting LXC 201 if stopped ...
|
||||
[2026-02-18T08:01:25+00:00] LXC: installing flash scripts ...
|
||||
failed to create file: /opt/cm4-provisioning/: Is a directory
|
||||
[2026-02-18T08:01:45+00:00] LXC: installing dashboard ...
|
||||
failed to create file: /opt/cm4-provisioning/dashboard/: Is a directory
|
||||
failed to create file: /opt/cm4-provisioning/dashboard/templates/: Is a directory
|
||||
[2026-02-18T08:01:59+00:00] Deploy done (remote).
|
||||
Next: Install usbboot on host when online: ssh <host> 'bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh'
|
||||
Next: Enable dashboard in LXC 201: pct exec 201 -- bash -c 'apt-get install -y python3-flask; cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/; systemctl daemon-reload; systemctl enable --now cm4-dashboard'
|
||||
failed to create file: /opt/cm4-provisioning/dashboard/: Is a directory
|
||||
[2026-02-18T09:58:31+02:00] [4/4] Deploy finished.
|
||||
|
||||
Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/).
|
||||
When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
|
||||
Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see docs/PROXMOX-LXC-DEPLOYMENT.md).
|
||||
Log written to: /home/nearxos/Projects/reTerminal DM4/chromium-setup/emmc-provisioning/scripts/deploy-20260218-095749.log
|
||||
20
emmc-provisioning/scripts/deploy-20260218-095859.log
Normal file
20
emmc-provisioning/scripts/deploy-20260218-095859.log
Normal file
@@ -0,0 +1,20 @@
|
||||
[2026-02-18T09:58:59+02:00] Logging to /home/nearxos/Projects/reTerminal DM4/chromium-setup/emmc-provisioning/scripts/deploy-20260218-095859.log
|
||||
[2026-02-18T09:58:59+02:00] Deploying to root@10.130.60.224 ...
|
||||
[2026-02-18T09:58:59+02:00] [1/4] Cleaning remote staging dir ...
|
||||
[2026-02-18T09:59:00+02:00] [2/4] Rsync repo to root@10.130.60.224 ...
|
||||
[2026-02-18T09:59:00+02:00] [3/4] Running remote install (host + LXC) ...
|
||||
[2026-02-18T08:02:32+00:00] LXC 201 already exists.
|
||||
[2026-02-18T08:02:32+00:00] Host: installing scripts and udev ...
|
||||
[2026-02-18T08:02:32+00:00] Host: env and dirs ...
|
||||
[2026-02-18T08:02:32+00:00] Starting LXC 201 if stopped ...
|
||||
[2026-02-18T08:02:35+00:00] LXC: installing flash scripts ...
|
||||
[2026-02-18T08:02:55+00:00] LXC: installing dashboard ...
|
||||
[2026-02-18T08:03:09+00:00] Deploy done (remote).
|
||||
Next: Install usbboot on host when online: ssh <host> 'bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh'
|
||||
Next: Enable dashboard in LXC 201: pct exec 201 -- bash -c 'apt-get install -y python3-flask; cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/; systemctl daemon-reload; systemctl enable --now cm4-dashboard'
|
||||
[2026-02-18T09:59:41+02:00] [4/4] Deploy finished.
|
||||
|
||||
Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/).
|
||||
When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
|
||||
Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see docs/PROXMOX-LXC-DEPLOYMENT.md).
|
||||
Log written to: /home/nearxos/Projects/reTerminal DM4/chromium-setup/emmc-provisioning/scripts/deploy-20260218-095859.log
|
||||
20
emmc-provisioning/scripts/deploy-20260218-101119.log
Normal file
20
emmc-provisioning/scripts/deploy-20260218-101119.log
Normal file
@@ -0,0 +1,20 @@
|
||||
[2026-02-18T10:11:19+02:00] Logging to /home/nearxos/Projects/reTerminal DM4/chromium-setup/emmc-provisioning/scripts/deploy-20260218-101119.log
|
||||
[2026-02-18T10:11:19+02:00] Deploying to root@10.130.60.224 ...
|
||||
[2026-02-18T10:11:19+02:00] [1/4] Cleaning remote staging dir ...
|
||||
[2026-02-18T10:11:20+02:00] [2/4] Rsync repo to root@10.130.60.224 ...
|
||||
[2026-02-18T10:11:21+02:00] [3/4] Running remote install (host + LXC) ...
|
||||
[2026-02-18T08:14:52+00:00] LXC 201 already exists.
|
||||
[2026-02-18T08:14:52+00:00] Host: installing scripts and udev ...
|
||||
[2026-02-18T08:14:52+00:00] Host: env and dirs ...
|
||||
[2026-02-18T08:14:52+00:00] Starting LXC 201 if stopped ...
|
||||
[2026-02-18T08:14:56+00:00] LXC: installing flash scripts ...
|
||||
[2026-02-18T08:15:16+00:00] LXC: installing dashboard ...
|
||||
[2026-02-18T08:15:30+00:00] Deploy done (remote).
|
||||
Next: Install usbboot on host when online: ssh <host> 'bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh'
|
||||
Next: Enable dashboard in LXC 201: pct exec 201 -- bash -c 'apt-get install -y python3-flask; cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/; systemctl daemon-reload; systemctl enable --now cm4-dashboard'
|
||||
[2026-02-18T10:12:02+02:00] [4/4] Deploy finished.
|
||||
|
||||
Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/).
|
||||
When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
|
||||
Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see docs/PROXMOX-LXC-DEPLOYMENT.md).
|
||||
Log written to: /home/nearxos/Projects/reTerminal DM4/chromium-setup/emmc-provisioning/scripts/deploy-20260218-101119.log
|
||||
35
emmc-provisioning/scripts/deploy-dashboard-to-lxc.sh
Executable file
35
emmc-provisioning/scripts/deploy-dashboard-to-lxc.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy only the dashboard to the LXC by IP (no Proxmox host needed).
|
||||
# Uses rsync so all files (app, templates, service file, etc.) stay in sync.
|
||||
# Usage: ./deploy-dashboard-to-lxc.sh [user@lxc_ip]
|
||||
# Example: ./deploy-dashboard-to-lxc.sh root@10.130.60.119
|
||||
|
||||
set -e
|
||||
LXC="${1:-root@10.130.60.119}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DASHBOARD_DIR="$REPO_DIR/dashboard"
|
||||
REMOTE_DIR="/opt/cm4-provisioning/dashboard"
|
||||
|
||||
if [[ ! -d "$DASHBOARD_DIR" ]]; then
|
||||
echo "Error: dashboard dir not found: $DASHBOARD_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploying dashboard to $LXC ($REMOTE_DIR) ..."
|
||||
# Ensure remote has rsync (Debian/Ubuntu LXC)
|
||||
ssh "$LXC" "command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y rsync)"
|
||||
|
||||
# Sync entire dashboard: app, templates, service file, README, requirements, etc.
|
||||
rsync -avz --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.env' \
|
||||
"$DASHBOARD_DIR/" \
|
||||
"$LXC:$REMOTE_DIR/"
|
||||
|
||||
echo "Installing systemd unit and restarting ..."
|
||||
ssh "$LXC" "cp $REMOTE_DIR/cm4-dashboard.service /etc/systemd/system/ && systemctl daemon-reload && systemctl restart cm4-dashboard && systemctl is-active --quiet cm4-dashboard && echo 'Dashboard restarted and running.'"
|
||||
|
||||
echo "Done. Dashboard at http://$(echo "$LXC" | cut -d@ -f2):5000"
|
||||
387
emmc-provisioning/scripts/deploy-to-proxmox.sh
Executable file
387
emmc-provisioning/scripts/deploy-to-proxmox.sh
Executable file
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy CM4 eMMC provisioning to a Proxmox host: host scripts, udev, systemd units,
|
||||
# LXC container (dashboard), usbboot (rpiboot), and PiShrink. Uses hostname "cm4-provisioning"
|
||||
# to find the container on redeploy; creates with next available ID if not found.
|
||||
#
|
||||
# Redeploy (re-run) behaviour: checks first if the cm4-provisioning container exists;
|
||||
# if so, skips storage selection. Skips other steps that are already configured so you
|
||||
# can update only what changed. Always updates: host scripts, dashboard files, env,
|
||||
# systemd/udev. Skips when present: LXC creation, backups bind-mount (if same path),
|
||||
# usbboot, PiShrink, LXC python3-flask and openssh-server apt installs. Set
|
||||
# DEPLOY_ROOTFS_STORAGE to avoid storage prompt on first deploy (when container is new).
|
||||
#
|
||||
# With host internet: installs usbboot and PiShrink so USB flash/backup and dashboard
|
||||
# Shrink/Compress work. The only manual step left is to add a golden image for Deploy.
|
||||
#
|
||||
# Usage: ./deploy-to-proxmox.sh [proxmox_host]
|
||||
# Example: ./deploy-to-proxmox.sh root@10.20.30.152
|
||||
#
|
||||
# Optional env:
|
||||
# DEPLOY_ROOTFS_STORAGE=name — LXC rootfs storage (if set and valid, skips interactive choice; otherwise script lists storages and asks for number)
|
||||
# CM4_BACKUPS_HOST_PATH=/path — host dir for backups; bind-mounted into LXC
|
||||
# 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
|
||||
#
|
||||
# Requires: ssh key access to root@<host>. For full install (usbboot, PiShrink), host needs internet.
|
||||
|
||||
set -e
|
||||
PROXMOX="${1:-root@10.130.60.224}"
|
||||
# ROOTFS_STORAGE set later: from DEPLOY_ROOTFS_STORAGE (if in host list) or from interactive choice
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
LOG_FILE=""
|
||||
if [[ -n "${DEPLOY_LOG:-}" ]]; then
|
||||
LOG_FILE="$SCRIPT_DIR/deploy-$(date +%Y%m%d-%H%M%S).log"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
echo "[$(date -Iseconds)] Logging to $LOG_FILE"
|
||||
fi
|
||||
|
||||
log() { echo "[$(date -Iseconds)] $*"; }
|
||||
|
||||
# Optional: gather SSH key and LXC root password for setup inside deploy
|
||||
# Default LXC root password (base64); override with DEPLOY_LXC_ROOT_PASSWORD. Not secure if repo is shared.
|
||||
DEFAULT_LXC_PWD_B64="c2gxcGIweDE="
|
||||
DEPLOY_SSH_KEY_B64=""
|
||||
DEPLOY_LXC_PWD_B64=""
|
||||
if [[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" ]]; then
|
||||
DEPLOY_LXC_PWD_B64=$(echo -n "$DEPLOY_LXC_ROOT_PASSWORD" | base64 -w 0 2>/dev/null || base64 2>/dev/null | tr -d '\n')
|
||||
log "Will set LXC root password (from DEPLOY_LXC_ROOT_PASSWORD)."
|
||||
else
|
||||
DEPLOY_LXC_PWD_B64="$DEFAULT_LXC_PWD_B64"
|
||||
log "Will set LXC root password (default)."
|
||||
fi
|
||||
KEY_FILE="${DEPLOY_LXC_SSH_KEY:-}"
|
||||
if [[ -z "$KEY_FILE" ]]; then
|
||||
for f in ~/.ssh/id_ed25519.pub ~/.ssh/id_rsa.pub; do
|
||||
[[ -f "$f" ]] && { KEY_FILE="$f"; break; }
|
||||
done
|
||||
fi
|
||||
if [[ -n "$KEY_FILE" && -f "$KEY_FILE" ]]; then
|
||||
DEPLOY_SSH_KEY_B64=$(base64 -w 0 < "$KEY_FILE" 2>/dev/null || base64 < "$KEY_FILE" 2>/dev/null | tr -d '\n')
|
||||
log "Will copy SSH key to LXC: $KEY_FILE"
|
||||
fi
|
||||
|
||||
log "Deploying to $PROXMOX ..."
|
||||
log "[1/5] Checking if cm4-provisioning container exists and listing storage ..."
|
||||
REMOTE_CHECK=$(ssh "$PROXMOX" "bash -s" << 'REMOTECHECK'
|
||||
LXC_HOSTNAME="cm4-provisioning"
|
||||
LXC_EXISTS=0
|
||||
for id in $(pct list 2>/dev/null | awk 'NR>1 {print $1}'); do
|
||||
h=$(pct config "$id" 2>/dev/null | sed -n 's/^hostname: *//p')
|
||||
if [[ "$h" == "$LXC_HOSTNAME" ]]; then LXC_EXISTS=1; break; fi
|
||||
done
|
||||
echo "LXC_EXISTS=$LXC_EXISTS"
|
||||
pvesm status 2>/dev/null | awk 'NR>1 { sub(/^[ \t]+/, ""); if ($2!="pbs" && $3~/active/) print $1 }'
|
||||
REMOTECHECK
|
||||
)
|
||||
|
||||
first_line=
|
||||
storages=()
|
||||
while IFS= read -r line; do
|
||||
if [[ -z "$first_line" ]]; then
|
||||
first_line="$line"
|
||||
else
|
||||
[[ -n "$line" ]] && storages+=("$line")
|
||||
fi
|
||||
done <<< "$REMOTE_CHECK"
|
||||
|
||||
if [[ "$first_line" == "LXC_EXISTS=1" ]]; then
|
||||
LXC_ALREADY_EXISTS=1
|
||||
log "Container cm4-provisioning already exists; no storage selection needed."
|
||||
if [[ ${#storages[@]} -eq 0 ]]; then
|
||||
log "Error: no Proxmox storage found on host (needed for validation). Run on host: pvesm status"
|
||||
exit 1
|
||||
fi
|
||||
ROOTFS_STORAGE="${storages[0]}"
|
||||
log "Using storage $ROOTFS_STORAGE for validation only."
|
||||
else
|
||||
LXC_ALREADY_EXISTS=0
|
||||
if [[ ${#storages[@]} -eq 0 ]]; then
|
||||
log "Error: no Proxmox storage found on host (PBS excluded). Run on host: pvesm status"
|
||||
exit 1
|
||||
fi
|
||||
# --- Ask user to select LXC rootfs storage only when creating new container ---
|
||||
if [[ -n "${DEPLOY_ROOTFS_STORAGE:-}" ]]; then
|
||||
for s in "${storages[@]}"; do
|
||||
if [[ "$s" == "$DEPLOY_ROOTFS_STORAGE" ]]; then
|
||||
ROOTFS_STORAGE="$DEPLOY_ROOTFS_STORAGE"
|
||||
log "Using storage: $ROOTFS_STORAGE (from DEPLOY_ROOTFS_STORAGE)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${ROOTFS_STORAGE:-}" ]]; then
|
||||
echo ""
|
||||
echo "Available storage on $PROXMOX (PBS excluded):"
|
||||
for i in "${!storages[@]}"; do echo " $((i+1))) ${storages[i]}"; done
|
||||
echo ""
|
||||
if [[ ${#storages[@]} -eq 1 ]]; then
|
||||
ROOTFS_STORAGE="${storages[0]}"
|
||||
log "Using only available storage: $ROOTFS_STORAGE"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
ROOTFS_STORAGE="${storages[0]}"
|
||||
log "No TTY: using first storage $ROOTFS_STORAGE"
|
||||
else
|
||||
while true; do
|
||||
read -r -p "Select storage (1-${#storages[@]}): " num
|
||||
if [[ "$num" =~ ^[0-9]+$ ]] && [[ "$num" -ge 1 ]] && [[ "$num" -le ${#storages[@]} ]]; then
|
||||
ROOTFS_STORAGE="${storages[num-1]}"
|
||||
log "Using storage: $ROOTFS_STORAGE"
|
||||
break
|
||||
fi
|
||||
echo "Invalid choice. Enter a number from 1 to ${#storages[@]}."
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
log "[2/5] Cleaning remote staging dir ..."
|
||||
ssh "$PROXMOX" "rm -rf /tmp/emmc-provisioning-deploy"
|
||||
log "[3/5] Rsync repo to $PROXMOX ..."
|
||||
rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh' --exclude='scripts/deploy-*.log'
|
||||
|
||||
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'
|
||||
set -e
|
||||
DEPLOY=/tmp/emmc-provisioning-deploy
|
||||
ROOTFS_STORAGE="${ROOTFS_STORAGE:?ROOTFS_STORAGE not set}"
|
||||
BACKUPS_HOST_PATH="${CM4_BACKUPS_HOST_PATH:-}"
|
||||
LXC_HOSTNAME="cm4-provisioning"
|
||||
log() { echo "[$(date -Iseconds)] $*"; }
|
||||
|
||||
# Storage already chosen interactively; validate it exists on host (match by name + active, robust to column alignment)
|
||||
if ! pvesm status 2>/dev/null | grep -w "$ROOTFS_STORAGE" | grep -q active; then
|
||||
log "Error: storage $ROOTFS_STORAGE not found or not valid on host"
|
||||
exit 1
|
||||
fi
|
||||
log "Using storage: $ROOTFS_STORAGE"
|
||||
|
||||
# --- Find existing LXC by hostname or use next available ID ---
|
||||
CTID=""
|
||||
for id in $(pct list 2>/dev/null | awk 'NR>1 {print $1}'); do
|
||||
h=$(pct config "$id" 2>/dev/null | sed -n 's/^hostname: *//p')
|
||||
if [[ "$h" == "$LXC_HOSTNAME" ]]; then
|
||||
CTID="$id"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "$CTID" ]]; then
|
||||
log "Found existing LXC $CTID (hostname: $LXC_HOSTNAME)."
|
||||
else
|
||||
MAX_ID=$(pct list 2>/dev/null | awk 'NR>1 {print $1}' | sort -n | tail -1)
|
||||
[[ -z "$MAX_ID" ]] && MAX_ID=0
|
||||
CTID=$((MAX_ID + 1))
|
||||
log "Creating LXC $CTID ($LXC_HOSTNAME) (rootfs on ${ROOTFS_STORAGE})..."
|
||||
VZTMPL_DIR=/var/lib/vz/template/cache
|
||||
DEBIAN12_TMPL=$(ls "$VZTMPL_DIR"/debian-12-standard_*.tar.zst 2>/dev/null | head -1)
|
||||
if [[ -z "$DEBIAN12_TMPL" ]]; then
|
||||
log "Downloading Debian 12 LXC template..."
|
||||
pveam download local debian-12-standard_12.12-1_amd64.tar.zst || pveam download local debian-12-standard_12.7-1_amd64.tar.zst
|
||||
DEBIAN12_TMPL=$(ls "$VZTMPL_DIR"/debian-12-standard_*.tar.zst 2>/dev/null | head -1)
|
||||
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"
|
||||
NET1_OPT=""
|
||||
if [[ -n "${DEPLOY_LXC_NET1:-}" ]]; then
|
||||
NET1_OPT="--net1 $DEPLOY_LXC_NET1"
|
||||
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 \
|
||||
--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
|
||||
log "LXC $CTID created and mount configured."
|
||||
fi
|
||||
|
||||
# Optional: bind-mount host directory for backup images (skip if already mounted with same path)
|
||||
if [[ -n "$BACKUPS_HOST_PATH" ]]; then
|
||||
BACKUPS_PATH_NORM="${BACKUPS_HOST_PATH%/}"
|
||||
mkdir -p "$BACKUPS_HOST_PATH"
|
||||
CURRENT_MP1=$(pct config "$CTID" 2>/dev/null | sed -n 's/^mp1: *//p')
|
||||
NEED_MOUNT=1
|
||||
if [[ -n "$CURRENT_MP1" ]]; then
|
||||
if [[ "$CURRENT_MP1" == *"mp=/var/lib/cm4-provisioning/backups"* ]] && { [[ "$CURRENT_MP1" == *"$BACKUPS_PATH_NORM"* ]] || [[ "$CURRENT_MP1" == *"$BACKUPS_HOST_PATH"* ]]; }; then
|
||||
NEED_MOUNT=0
|
||||
log "Backups mount already configured (host $BACKUPS_PATH_NORM), skipping."
|
||||
fi
|
||||
fi
|
||||
if [[ "$NEED_MOUNT" -eq 1 ]]; then
|
||||
pct stop "$CTID" 2>/dev/null || true
|
||||
pct set "$CTID" -mp1 "$BACKUPS_HOST_PATH",mp=/var/lib/cm4-provisioning/backups
|
||||
pct start "$CTID" 2>/dev/null || true
|
||||
log "Backups mount: host $BACKUPS_HOST_PATH -> LXC $CTID /var/lib/cm4-provisioning/backups"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Host: scripts, systemd, udev (always update so changes are applied) ---
|
||||
HOST_PROV_EXISTS=0
|
||||
[[ -f /opt/cm4-provisioning/flash-emmc-on-connect.sh ]] && HOST_PROV_EXISTS=1
|
||||
if [[ "$HOST_PROV_EXISTS" -eq 1 ]]; then
|
||||
log "Host: updating scripts and systemd units (already configured) ..."
|
||||
else
|
||||
log "Host: installing scripts and systemd units ..."
|
||||
fi
|
||||
mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
|
||||
cp "$DEPLOY/host/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
|
||||
chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
cp "$DEPLOY/host/build-cloudinit-image.sh" /opt/cm4-provisioning/
|
||||
chmod +x /opt/cm4-provisioning/build-cloudinit-image.sh
|
||||
cp "$DEPLOY/host/run-shrink-on-host.sh" /opt/cm4-provisioning/
|
||||
chmod +x /opt/cm4-provisioning/run-shrink-on-host.sh
|
||||
cp "$DEPLOY/scripts/fix-gadget-bootcode-on-host.sh" /opt/cm4-provisioning/ 2>/dev/null && chmod +x /opt/cm4-provisioning/fix-gadget-bootcode-on-host.sh || true
|
||||
cp "$DEPLOY/host/cm4-flash-trigger.sh" /usr/local/bin/
|
||||
chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||
cp "$DEPLOY/host/cm4-flash.service" /etc/systemd/system/
|
||||
cp "$DEPLOY/host/cm4-build-cloudinit.path" /etc/systemd/system/
|
||||
cp "$DEPLOY/host/cm4-build-cloudinit.service" /etc/systemd/system/
|
||||
cp "$DEPLOY/host/cm4-shrink.path" /etc/systemd/system/
|
||||
cp "$DEPLOY/host/cm4-shrink.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cm4-build-cloudinit.path 2>/dev/null || true
|
||||
systemctl enable --now cm4-shrink.path 2>/dev/null || true
|
||||
cp "$DEPLOY/host/89-cm4-boot-mode-permissions.rules" /etc/udev/rules.d/ 2>/dev/null || true
|
||||
cp "$DEPLOY/host/90-cm4-boot-mode.rules" /etc/udev/rules.d/
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
|
||||
log "Host: env and dirs ..."
|
||||
cat > /opt/cm4-provisioning/env << 'ENV'
|
||||
GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img
|
||||
RPIBOOT_DIR=/opt/usbboot
|
||||
EMMC_SIZE_BYTES=8589934592
|
||||
ENV
|
||||
[[ -n "$BACKUPS_HOST_PATH" ]] && echo "BACKUPS_DIR=$BACKUPS_HOST_PATH" >> /opt/cm4-provisioning/env
|
||||
touch /etc/cm4-provisioning/enabled
|
||||
mkdir -p /var/lib/cm4-provisioning/backups /var/lib/cm4-provisioning/cloudinit-images /var/lib/cm4-provisioning/portal-files /var/lib/cm4-provisioning/download-cache
|
||||
[[ -n "$BACKUPS_HOST_PATH" ]] && mkdir -p "$BACKUPS_HOST_PATH"
|
||||
grep -q "CLOUDINIT_IMAGES_DIR" /opt/cm4-provisioning/env || echo "CLOUDINIT_IMAGES_DIR=/var/lib/cm4-provisioning/cloudinit-images" >> /opt/cm4-provisioning/env
|
||||
grep -q "CM4_DOWNLOAD_CACHE_DIR" /opt/cm4-provisioning/env || echo "CM4_DOWNLOAD_CACHE_DIR=/var/lib/cm4-provisioning/download-cache" >> /opt/cm4-provisioning/env
|
||||
|
||||
# --- Host: ensure xz-utils (and file) for cloud-init image build (decompress .img.xz) ---
|
||||
if command -v xz >/dev/null 2>&1; then
|
||||
log "Host: xz-utils already present, skipping."
|
||||
else
|
||||
log "Host: installing xz-utils and file (required for cloud-init image decompress)..."
|
||||
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq xz-utils file 2>/dev/null || log "Warning: could not install xz-utils (no internet?). Cloud-init build will fail at decompress until you run: apt install -y xz-utils file"
|
||||
fi
|
||||
|
||||
# --- Host: install usbboot (rpiboot) only if not already present ---
|
||||
if [[ -x /opt/usbboot/rpiboot ]] || [[ -f /opt/usbboot/rpiboot ]]; then
|
||||
log "Host: usbboot already installed at /opt/usbboot/rpiboot, skipping."
|
||||
else
|
||||
log "Host: installing usbboot (rpiboot)..."
|
||||
if bash "$DEPLOY/scripts/install-usbboot-on-host.sh" 2>&1; then
|
||||
log "Host: usbboot installed at /opt/usbboot/rpiboot"
|
||||
else
|
||||
log "Warning: usbboot install failed (e.g. no internet). USB flash/backup will not work until you run: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Host: install PiShrink only if not already present ---
|
||||
if [[ -x /usr/local/bin/pishrink.sh ]] || [[ -f /usr/local/bin/pishrink.sh ]]; then
|
||||
log "Host: PiShrink already installed, skipping."
|
||||
grep -q "SHRINK_BACKUP" /opt/cm4-provisioning/env || echo "SHRINK_BACKUP=1" >> /opt/cm4-provisioning/env
|
||||
grep -q "PISHRINK_COMPRESS" /opt/cm4-provisioning/env || echo "PISHRINK_COMPRESS=xz" >> /opt/cm4-provisioning/env
|
||||
else
|
||||
log "Host: installing PiShrink..."
|
||||
if bash "$DEPLOY/scripts/install-pishrink-on-host.sh" 2>&1; then
|
||||
log "Host: PiShrink installed"
|
||||
grep -q "SHRINK_BACKUP" /opt/cm4-provisioning/env || echo "SHRINK_BACKUP=1" >> /opt/cm4-provisioning/env
|
||||
grep -q "PISHRINK_COMPRESS" /opt/cm4-provisioning/env || echo "PISHRINK_COMPRESS=xz" >> /opt/cm4-provisioning/env
|
||||
else
|
||||
log "Warning: PiShrink install failed (e.g. no internet). Dashboard Shrink/Compress will report 'PiShrink not installed' until you run: bash /tmp/emmc-provisioning-deploy/scripts/install-pishrink-on-host.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Start LXC if stopped ---
|
||||
log "Starting LXC $CTID if stopped ..."
|
||||
pct start "$CTID" 2>/dev/null || true
|
||||
|
||||
# --- 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
|
||||
pct push "$CTID" "$DEPLOY/host/flash-emmc-on-connect.sh" /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
pct exec "$CTID" -- chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
pct push "$CTID" "$DEPLOY/host/cm4-flash-trigger.sh" /usr/local/bin/cm4-flash-trigger.sh
|
||||
pct exec "$CTID" -- chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||
pct exec "$CTID" -- bash -c 'echo -e "GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img\nRPIBOOT_DIR=/opt/usbboot\nEMMC_SIZE_BYTES=8589934592" > /opt/cm4-provisioning/env'
|
||||
|
||||
# --- LXC: dashboard (all files) ---
|
||||
log "LXC: installing dashboard ..."
|
||||
pct exec "$CTID" -- mkdir -p /opt/cm4-provisioning/dashboard/templates
|
||||
pct push "$CTID" "$DEPLOY/dashboard/app.py" /opt/cm4-provisioning/dashboard/app.py
|
||||
pct push "$CTID" "$DEPLOY/dashboard/templates/home.html" /opt/cm4-provisioning/dashboard/templates/home.html
|
||||
pct push "$CTID" "$DEPLOY/dashboard/templates/login.html" /opt/cm4-provisioning/dashboard/templates/login.html
|
||||
pct push "$CTID" "$DEPLOY/dashboard/templates/admin.html" /opt/cm4-provisioning/dashboard/templates/admin.html
|
||||
pct push "$CTID" "$DEPLOY/dashboard/cm4-dashboard.service" /opt/cm4-provisioning/dashboard/cm4-dashboard.service
|
||||
# Dashboard secret for sessions (create once so logins persist across restarts)
|
||||
pct exec "$CTID" -- bash -c '[[ -f /opt/cm4-provisioning/dashboard.env ]] || echo "CM4_DASHBOARD_SECRET_KEY=$(openssl rand -hex 24 2>/dev/null || head -c 24 /dev/urandom | xxd -p)" > /opt/cm4-provisioning/dashboard.env'
|
||||
|
||||
# --- LXC: Flask and systemd (skip apt install if flask already present) ---
|
||||
if pct exec "$CTID" -- dpkg -l python3-flask 2>/dev/null | grep -q '^ii'; then
|
||||
log "LXC: python3-flask already installed, skipping apt install."
|
||||
else
|
||||
log "LXC: installing python3-flask ..."
|
||||
pct exec "$CTID" -- bash -c 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-flask python3-werkzeug'
|
||||
fi
|
||||
pct exec "$CTID" -- cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
|
||||
pct exec "$CTID" -- systemctl daemon-reload
|
||||
pct exec "$CTID" -- systemctl enable --now cm4-dashboard
|
||||
pct exec "$CTID" -- systemctl restart cm4-dashboard
|
||||
log "LXC: cm4-dashboard enabled and restarted (new code loaded)."
|
||||
|
||||
# --- LXC: optional SSH (root password + SSH key from deploy env) ---
|
||||
if [[ -n "${DEPLOY_SSH_KEY_B64:-}" ]] || [[ -n "${DEPLOY_LXC_PWD_B64:-}" ]]; then
|
||||
log "LXC: configuring SSH (root login, password, authorized_keys)..."
|
||||
if pct exec "$CTID" -- dpkg -l openssh-server 2>/dev/null | grep -q '^ii'; then
|
||||
log "LXC: openssh-server already installed, skipping apt install."
|
||||
else
|
||||
pct exec "$CTID" -- bash -c 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server 2>/dev/null' || true
|
||||
fi
|
||||
pct exec "$CTID" -- bash -c 'systemctl enable ssh 2>/dev/null; systemctl start ssh 2>/dev/null' || true
|
||||
pct exec "$CTID" -- bash -c 'sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config 2>/dev/null; grep -q "^PermitRootLogin" /etc/ssh/sshd_config || echo "PermitRootLogin yes" >> /etc/ssh/sshd_config; systemctl restart ssh 2>/dev/null || systemctl restart sshd 2>/dev/null' || true
|
||||
if [[ -n "${DEPLOY_LXC_PWD_B64:-}" ]]; then
|
||||
PWD_RAW=$(echo "$DEPLOY_LXC_PWD_B64" | base64 -d 2>/dev/null)
|
||||
echo "root:$PWD_RAW" | pct exec "$CTID" -- chpasswd 2>/dev/null && log "LXC: root password set." || true
|
||||
fi
|
||||
if [[ -n "${DEPLOY_SSH_KEY_B64:-}" ]]; then
|
||||
echo "$DEPLOY_SSH_KEY_B64" | base64 -d 2>/dev/null | pct exec "$CTID" -- bash -c "mkdir -p /root/.ssh; chmod 700 /root/.ssh; cat >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys" 2>/dev/null && log "LXC: SSH key added to /root/.ssh/authorized_keys." || true
|
||||
fi
|
||||
LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')
|
||||
[[ -n "$LXC_IP" ]] && log "LXC SSH: ssh root@$LXC_IP"
|
||||
fi
|
||||
# Always capture LXC IP for final summary (write so local script can read it)
|
||||
LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')
|
||||
echo "${LXC_IP:-}" > "$DEPLOY/lxc_ip.txt"
|
||||
|
||||
log "Deploy done on remote. LXC ID: $CTID"
|
||||
# Heredoc terminator (must be at column 1, no leading space/tab)
|
||||
REMOTE
|
||||
|
||||
# Read LXC IP written by remote (container hostname -I)
|
||||
LXC_IP=$(ssh "$PROXMOX" "cat /tmp/emmc-provisioning-deploy/lxc_ip.txt 2>/dev/null" | tr -d '\n\r')
|
||||
|
||||
log "[5/5] Deploy finished."
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo "Host and LXC are fully set up: usbboot (rpiboot), PiShrink, dashboard, systemd, udev."
|
||||
[[ -n "$LXC_IP" ]] && echo " LXC IP: $LXC_IP"
|
||||
echo ""
|
||||
echo "--- Only remaining step (manual) ---"
|
||||
echo " Add a golden image for Deploy (writing image to device):"
|
||||
echo " • Dashboard: open http://${LXC_IP:-<LXC-IP>}:5000 → Build cloud-init image → then Set as golden"
|
||||
echo " • Or copy your image: scp your-image.img $PROXMOX:/var/lib/cm4-provisioning/golden.img"
|
||||
echo " Backup (read from device) works without golden.img."
|
||||
echo ""
|
||||
echo "--- You have ---"
|
||||
echo " - Dashboard: http://${LXC_IP:-<LXC-IP>}:5000"
|
||||
[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && [[ -n "$LXC_IP" ]] && echo " - LXC SSH: ssh root@$LXC_IP (password and/or key were set)"
|
||||
[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && [[ -z "$LXC_IP" ]] && echo " - LXC SSH: ssh root@<LXC-IP> (password and/or key were set)"
|
||||
[[ -n "${CM4_BACKUPS_HOST_PATH:-}" ]] && echo " - Backups on host: $CM4_BACKUPS_HOST_PATH"
|
||||
if [[ -n "$LOG_FILE" ]]; then
|
||||
echo " - Log: $LOG_FILE"
|
||||
fi
|
||||
56
emmc-provisioning/scripts/fix-gadget-bootcode-on-host.sh
Normal file
56
emmc-provisioning/scripts/fix-gadget-bootcode-on-host.sh
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox host (as root) when rpiboot fails with "No 'bootcode' files found" / "rpiboot gadget empty".
|
||||
# Cause: mass-storage-gadget64 has no real boot files (broken symlinks or Git LFS not pulled).
|
||||
# This script removes broken symlinks and extracts bootcode4.bin from the installed rpiboot binary.
|
||||
#
|
||||
# On host: bash fix-gadget-bootcode-on-host.sh
|
||||
# From your machine: ssh root@HOST 'bash -s' < emmc-provisioning/scripts/fix-gadget-bootcode-on-host.sh
|
||||
|
||||
set -e
|
||||
GADGET="${1:-/opt/usbboot/mass-storage-gadget64}"
|
||||
RPIBOOT="${2:-/opt/usbboot/rpiboot}"
|
||||
|
||||
[[ -d "$GADGET" ]] || { echo "Error: $GADGET not found"; exit 1; }
|
||||
[[ -x "$RPIBOOT" ]] || { echo "Error: $RPIBOOT not found (install usbboot first: install-usbboot-on-host.sh)"; exit 1; }
|
||||
|
||||
# Ensure readelf and objdump are available (binutils)
|
||||
for cmd in readelf objdump; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "Installing binutils (required for $cmd)..."
|
||||
apt-get update -qq && apt-get install -y -qq binutils 2>/dev/null || {
|
||||
echo "Error: $cmd not found. Install binutils: apt-get install -y binutils"
|
||||
exit 1
|
||||
}
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove broken symlinks in gadget dir so we can replace with real boot file
|
||||
for f in bootfiles.bin bootcode.bin bootcode4.bin bootcode5.bin; do
|
||||
if [[ -L "$GADGET/$f" ]] && ! [[ -f "$GADGET/$f" ]]; then
|
||||
rm -f "$GADGET/$f"
|
||||
echo "Removed broken symlink $GADGET/$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# If we already have a valid boot file, done
|
||||
if [[ -f "$GADGET/bootcode4.bin" ]] || [[ -f "$GADGET/bootfiles.bin" ]]; then
|
||||
echo "Already has bootcode4.bin or valid bootfiles.bin. OK."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get .data section file offset and address from ELF
|
||||
readelf -S "$RPIBOOT" | grep -q "\.data" || { echo "No .data section"; exit 1; }
|
||||
DATA_OFF=$(readelf -S "$RPIBOOT" | awk '/\.data\s/ { print "0x"$5; exit }')
|
||||
DATA_ADDR=$(readelf -S "$RPIBOOT" | awk '/\.data\s/ { print "0x"$4; exit }')
|
||||
LEN_SYM=$(objdump -t "$RPIBOOT" | awk '/msd_bootcode4_bin_len/ { print $1; exit }')
|
||||
BIN_SYM=$(objdump -t "$RPIBOOT" | awk '/msd_bootcode4_bin\s/ { print $1; exit }')
|
||||
[[ -n "$LEN_SYM" && -n "$BIN_SYM" ]] || { echo "msd_bootcode4_bin symbols not found in rpiboot"; exit 1; }
|
||||
|
||||
LEN_ADDR=$((0x$LEN_SYM))
|
||||
BIN_ADDR=$((0x$BIN_SYM))
|
||||
LEN_OFF=$((DATA_OFF + LEN_ADDR - DATA_ADDR))
|
||||
BIN_OFF=$((DATA_OFF + BIN_ADDR - DATA_ADDR))
|
||||
LEN=$(dd if="$RPIBOOT" bs=1 skip=$LEN_OFF count=4 2>/dev/null | od -An -t u4 | tr -d ' ')
|
||||
dd if="$RPIBOOT" of="$GADGET/bootcode4.bin" bs=1 skip=$BIN_OFF count=$LEN 2>/dev/null
|
||||
echo "Wrote $GADGET/bootcode4.bin ($LEN bytes). Verify: $RPIBOOT -d $GADGET -V"
|
||||
54
emmc-provisioning/scripts/install-pishrink-on-host.sh
Normal file
54
emmc-provisioning/scripts/install-pishrink-on-host.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the provisioning HOST (root) to install PiShrink and dependencies.
|
||||
# Enables shrinking backups in flash-emmc-on-connect.sh when SHRINK_BACKUP=1.
|
||||
# PiShrink: https://github.com/Drewsif/PiShrink
|
||||
#
|
||||
# Offline install (when host has no internet):
|
||||
# 1. On a machine with internet: curl -Lo pishrink.sh https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||
# 2. scp pishrink.sh root@HOST:/tmp/
|
||||
# 3. On host: bash install-pishrink-on-host.sh /tmp/pishrink.sh
|
||||
#
|
||||
# Optional: PISHRINK_URL=... or pass path to local pishrink.sh as first argument.
|
||||
|
||||
set -e
|
||||
PISHRINK_URL="${PISHRINK_URL:-https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh}"
|
||||
LOCAL_PISHRINK="$1"
|
||||
|
||||
echo "Installing PiShrink dependencies (parted, e2fsprogs, xz-utils, ...)..."
|
||||
if apt-get update 2>/dev/null && apt-get install -y wget parted gzip pigz xz-utils udev e2fsprogs 2>/dev/null; then
|
||||
echo "Dependencies installed."
|
||||
else
|
||||
echo "Warning: apt install failed (e.g. host has no internet). Skipping packages."
|
||||
echo "PiShrink needs: parted, e2fsprogs; optional: gzip/pigz, xz-utils. Install them manually if missing."
|
||||
for cmd in parted resize2fs; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo " Missing: $cmd — install with apt when the host has network, or use a local mirror."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "$LOCAL_PISHRINK" && -f "$LOCAL_PISHRINK" ]]; then
|
||||
echo "Using local PiShrink script: $LOCAL_PISHRINK"
|
||||
cp "$LOCAL_PISHRINK" /usr/local/bin/pishrink.sh
|
||||
chmod +x /usr/local/bin/pishrink.sh
|
||||
echo "PiShrink installed at /usr/local/bin/pishrink.sh"
|
||||
elif command -v wget &>/dev/null && wget -q -O /usr/local/bin/pishrink.sh "$PISHRINK_URL" 2>/dev/null; then
|
||||
chmod +x /usr/local/bin/pishrink.sh
|
||||
echo "PiShrink installed at /usr/local/bin/pishrink.sh"
|
||||
else
|
||||
echo "Could not download PiShrink (no wget or no network)."
|
||||
echo ""
|
||||
echo "Offline install:"
|
||||
echo " 1. On a machine WITH internet run:"
|
||||
echo " curl -Lo pishrink.sh $PISHRINK_URL"
|
||||
echo " 2. Copy to host:"
|
||||
echo " scp pishrink.sh root@YOUR_HOST:/tmp/"
|
||||
echo " 3. On the host run:"
|
||||
echo " bash install-pishrink-on-host.sh /tmp/pishrink.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "To shrink backups automatically, add to /opt/cm4-provisioning/env:"
|
||||
echo " SHRINK_BACKUP=1"
|
||||
echo " # optional: PISHRINK_COMPRESS=gz or PISHRINK_COMPRESS=xz"
|
||||
30
emmc-provisioning/scripts/install-usbboot-on-host.sh
Normal file
30
emmc-provisioning/scripts/install-usbboot-on-host.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox HOST (root) when the host has internet.
|
||||
# Builds usbboot (rpiboot) and installs to /opt/usbboot so the auto-flash can run.
|
||||
# If the host has no internet, run build-and-deploy-usbboot-to-host.sh from your Fedora machine instead.
|
||||
|
||||
set -e
|
||||
apt-get update
|
||||
apt-get install -y libusb-1.0-0-dev git pkg-config build-essential
|
||||
cd /tmp
|
||||
rm -rf usbboot
|
||||
git clone --recurse-submodules --shallow-submodules --depth=1 https://github.com/raspberrypi/usbboot
|
||||
cd usbboot
|
||||
make
|
||||
mkdir -p /opt/usbboot
|
||||
cp rpiboot /opt/usbboot/
|
||||
# Copy gadget dir(s) so rpiboot -d works
|
||||
for dir in mass-storage-gadget64 mass-storage-gadget; do
|
||||
[[ -d "$dir" ]] && cp -a "$dir" /opt/usbboot/
|
||||
done
|
||||
echo "usbboot installed at /opt/usbboot/rpiboot"
|
||||
|
||||
# If rpiboot later fails with "No bootcode files" (broken symlinks in gadget), fix-gadget repairs it.
|
||||
# Run it now; prefer copy in /opt (from deploy) or in deploy dir.
|
||||
for fix in /opt/cm4-provisioning/fix-gadget-bootcode-on-host.sh /tmp/emmc-provisioning-deploy/scripts/fix-gadget-bootcode-on-host.sh; do
|
||||
if [[ -f "$fix" ]]; then
|
||||
echo "Running fix-gadget-bootcode-on-host.sh to ensure gadget has valid boot files..."
|
||||
bash "$fix" || true
|
||||
break
|
||||
fi
|
||||
done
|
||||
44
emmc-provisioning/scripts/monitor-from-host.sh
Executable file
44
emmc-provisioning/scripts/monitor-from-host.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox HOST to see provisioning status, USB, flash job, and logs.
|
||||
# Usage: ./monitor-from-host.sh or: ssh root@10.130.60.224 'bash -s' < scripts/monitor-from-host.sh
|
||||
|
||||
PROV=/var/lib/cm4-provisioning
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " CM4 provisioning – host monitor"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
echo "--- USB (CM4 boot mode: 2b8e or 0a5c:2711) ---"
|
||||
lsusb | grep -E "2b8e|0a5c" || echo " No CM4 boot device seen."
|
||||
echo ""
|
||||
|
||||
echo "--- Current status (dashboard reads this) ---"
|
||||
if [[ -f "$PROV/status.json" ]]; then
|
||||
cat "$PROV/status.json" | python3 -m json.tool 2>/dev/null || cat "$PROV/status.json"
|
||||
else
|
||||
echo " No status.json (no run yet)."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "--- Flash job (systemd unit) ---"
|
||||
systemctl show cm4-flash-once --property=ActiveState,SubState,ExecMainPID 2>/dev/null || echo " Unit not run yet."
|
||||
echo ""
|
||||
|
||||
echo "--- Last 20 lines of flash.log ---"
|
||||
tail -20 "$PROV/flash.log" 2>/dev/null || echo " No flash.log"
|
||||
echo ""
|
||||
|
||||
echo "--- Provisioning dir ---"
|
||||
ls -la "$PROV/" 2>/dev/null || echo " Dir missing."
|
||||
echo ""
|
||||
|
||||
echo "--- Block devices (eMMC appears here after rpiboot) ---"
|
||||
lsblk -d -o NAME,SIZE,MODEL,TRAN 2>/dev/null | head -15
|
||||
echo ""
|
||||
|
||||
echo "--- Config ---"
|
||||
echo " enabled: $([ -f /etc/cm4-provisioning/enabled ] && echo yes || echo no)"
|
||||
echo " rpiboot: $([ -x /opt/usbboot/rpiboot ] && echo /opt/usbboot/rpiboot || echo MISSING)"
|
||||
echo " golden: $([ -f $PROV/golden.img ] && echo present || echo not set)"
|
||||
echo " backups: $(ls "$PROV/backups" 2>/dev/null | wc -l) file(s)"
|
||||
35
emmc-provisioning/scripts/populate-gadget-on-host.sh
Executable file
35
emmc-provisioning/scripts/populate-gadget-on-host.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Populate /opt/usbboot/mass-storage-gadget64 on the Proxmox host (no rpiboot build).
|
||||
# Use when rpiboot works but you see "No 'bootcode' files found" — the gadget dir is empty.
|
||||
# Usage: ./populate-gadget-on-host.sh [proxmox_host]
|
||||
# Example: ./populate-gadget-on-host.sh root@10.130.60.224
|
||||
|
||||
set -e
|
||||
PROXMOX="${1:-root@10.130.60.224}"
|
||||
BUILD_DIR="/tmp/usbboot-gadget-$$"
|
||||
cleanup() { rm -rf "$BUILD_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[$(date -Iseconds)] Cloning usbboot to fetch mass-storage-gadget64 ..."
|
||||
mkdir -p "$BUILD_DIR"
|
||||
git clone --depth=1 https://github.com/raspberrypi/usbboot "$BUILD_DIR/usbboot"
|
||||
|
||||
GADGET="$BUILD_DIR/usbboot/mass-storage-gadget64"
|
||||
if [[ ! -d "$GADGET" ]]; then
|
||||
echo "Error: mass-storage-gadget64 not found in clone."
|
||||
exit 1
|
||||
fi
|
||||
# rpiboot needs at least bootfiles.bin or bootcode*.bin
|
||||
if [[ ! -f "$GADGET/bootfiles.bin" && ! -f "$GADGET/boot.img" ]]; then
|
||||
echo "Warning: clone has no bootfiles.bin or boot.img. Trying git lfs pull..."
|
||||
(cd "$BUILD_DIR/usbboot" && git lfs pull 2>/dev/null) || true
|
||||
fi
|
||||
if [[ ! -f "$GADGET/bootfiles.bin" && ! -f "$GADGET/boot.img" ]]; then
|
||||
echo "Error: mass-storage-gadget64 still has no boot files. Install git-lfs and retry, or run build-and-deploy-usbboot-to-host.sh for a full deploy."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$(date -Iseconds)] Syncing mass-storage-gadget64 to $PROXMOX:/opt/usbboot/ ..."
|
||||
ssh "$PROXMOX" "mkdir -p /opt/usbboot"
|
||||
rsync -a "$GADGET/" "$PROXMOX:/opt/usbboot/mass-storage-gadget64/"
|
||||
echo "Done. Verify with: ssh $PROXMOX 'ls -la /opt/usbboot/mass-storage-gadget64/'"
|
||||
47
emmc-provisioning/scripts/populate-tftpboot-from-git.sh
Executable file
47
emmc-provisioning/scripts/populate-tftpboot-from-git.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Populate /srv/tftpboot with Raspberry Pi 4 / CM4 boot files from the official firmware repo.
|
||||
# Run inside the LXC (as root), or from your machine: ./populate-tftpboot-from-git.sh root@10.130.60.141
|
||||
# Requires: curl or wget, tar; the LXC must have internet (eth0).
|
||||
|
||||
set -e
|
||||
TARGET="${1:-}"
|
||||
FIRMWARE_URL="https://github.com/raspberrypi/firmware/archive/refs/heads/master.tar.gz"
|
||||
TFTP_ROOT="${TFTP_ROOT:-/srv/tftpboot}"
|
||||
|
||||
do_populate() {
|
||||
echo "Populating $TFTP_ROOT from Raspberry Pi firmware (GitHub) ..."
|
||||
mkdir -p "$TFTP_ROOT"
|
||||
if [[ -f "$TFTP_ROOT/start4cd.elf" ]]; then
|
||||
echo "start4cd.elf already present; skipping download (remove it to re-fetch)."
|
||||
return 0
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
echo "Installing curl ..."
|
||||
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl
|
||||
fi
|
||||
local tmpdir
|
||||
tmpdir=$(mktemp -d)
|
||||
trap "rm -rf $tmpdir" EXIT
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sL "$FIRMWARE_URL" -o "$tmpdir/firmware.tar.gz"
|
||||
else
|
||||
wget -q -O "$tmpdir/firmware.tar.gz" "$FIRMWARE_URL"
|
||||
fi
|
||||
tar xzf "$tmpdir/firmware.tar.gz" -C "$tmpdir"
|
||||
if [[ ! -d "$tmpdir/firmware-master/boot" ]]; then
|
||||
echo "Error: boot folder not found in archive"
|
||||
exit 1
|
||||
fi
|
||||
cp -a "$tmpdir/firmware-master/boot/." "$TFTP_ROOT/"
|
||||
echo "Copied boot files to $TFTP_ROOT ($(ls "$TFTP_ROOT" | wc -l) items)."
|
||||
ls -la "$TFTP_ROOT"/start4*.elf "$TFTP_ROOT"/fixup4*.dat "$TFTP_ROOT"/config.txt 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [[ -n "$TARGET" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
scp "$SCRIPT_DIR/populate-tftpboot-from-git.sh" "$TARGET:/tmp/populate-tftpboot.sh"
|
||||
ssh "$TARGET" "bash /tmp/populate-tftpboot.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
do_populate
|
||||
75
emmc-provisioning/scripts/setup-lxc-ssh.sh
Executable file
75
emmc-provisioning/scripts/setup-lxc-ssh.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enable root SSH login on the cm4-provisioning LXC and add your SSH key.
|
||||
# Finds the container by hostname "cm4-provisioning" on the host, or use CTID=id to override.
|
||||
# Usage:
|
||||
# ./setup-lxc-ssh.sh [proxmox_host] [ssh_public_key_file]
|
||||
# ROOT_PASSWORD='yourpassword' ./setup-lxc-ssh.sh [proxmox_host] [ssh_public_key_file]
|
||||
# CTID=202 ./setup-lxc-ssh.sh root@10.130.60.224 # force a specific container ID
|
||||
#
|
||||
# If ssh_public_key_file is omitted, uses ~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub.
|
||||
|
||||
set -e
|
||||
PROXMOX="${1:-root@10.130.60.224}"
|
||||
KEY_FILE="${2:-}"
|
||||
CTID="${CTID:-}"
|
||||
|
||||
# Find public key
|
||||
if [[ -z "$KEY_FILE" ]]; then
|
||||
for f in ~/.ssh/id_ed25519.pub ~/.ssh/id_rsa.pub; do
|
||||
if [[ -f "$f" ]]; then
|
||||
KEY_FILE="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$KEY_FILE" || ! -f "$KEY_FILE" ]]; then
|
||||
echo "No SSH public key found. Usage: $0 [proxmox_host] [ssh_public_key_file]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KEY_CONTENT=$(cat "$KEY_FILE")
|
||||
ROOT_PASSWORD="${ROOT_PASSWORD:-}"
|
||||
|
||||
echo "Using key from: $KEY_FILE"
|
||||
echo "Configuring LXC (cm4-provisioning) on $PROXMOX (enable SSH, root login, add key)..."
|
||||
|
||||
ssh "$PROXMOX" "CTID='$CTID' KEY_CONTENT='$(echo "$KEY_CONTENT" | sed "s/'/'\\\\''/g")' ROOT_PASSWORD='$(echo "$ROOT_PASSWORD" | sed "s/'/'\\\\''/g")'" bash -s << 'REMOTE'
|
||||
set -e
|
||||
# Resolve CTID by hostname if not provided
|
||||
if [[ -z "$CTID" ]]; then
|
||||
CTID=$(pct list -no-header -output vmid,name 2>/dev/null | awk '$2=="cm4-provisioning"{print $1}' | head -1)
|
||||
fi
|
||||
if [[ -z "$CTID" ]]; then
|
||||
echo "Error: no container with hostname cm4-provisioning found. Set CTID=id and re-run."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using LXC ID: $CTID"
|
||||
|
||||
# Ensure container is running
|
||||
pct start $CTID 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Install openssh-server if missing, enable and start
|
||||
pct exec $CTID -- bash -c 'apt-get update -qq && apt-get install -y -qq openssh-server 2>/dev/null; systemctl enable ssh 2>/dev/null; systemctl start ssh 2>/dev/null' || true
|
||||
|
||||
# Enable root login via password and/or public key
|
||||
pct exec $CTID -- bash -c '
|
||||
sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config 2>/dev/null || true
|
||||
grep -q "^PermitRootLogin" /etc/ssh/sshd_config || echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||
systemctl restart ssh 2>/dev/null || systemctl restart sshd 2>/dev/null || true
|
||||
'
|
||||
|
||||
# Set root password if provided (pass via stdin so no quoting in -c)
|
||||
if [[ -n "$ROOT_PASSWORD" ]]; then
|
||||
echo "root:$ROOT_PASSWORD" | pct exec $CTID -- chpasswd
|
||||
echo "Root password set."
|
||||
fi
|
||||
|
||||
# Add SSH key to root (pass key via stdin to avoid quoting issues)
|
||||
echo "$KEY_CONTENT" | pct exec $CTID -- bash -c "mkdir -p /root/.ssh; chmod 700 /root/.ssh; cat >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys"
|
||||
echo "SSH key added to /root/.ssh/authorized_keys"
|
||||
|
||||
# Show IP for convenience
|
||||
IP=$(pct exec $CTID -- hostname -I 2>/dev/null | awk '{print $1}')
|
||||
echo "Done. Connect with: ssh root@$IP"
|
||||
REMOTE
|
||||
104
emmc-provisioning/scripts/setup-network-boot-on-lxc.sh
Executable file
104
emmc-provisioning/scripts/setup-network-boot-on-lxc.sh
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup network boot on the provisioning LXC: DHCP + TFTP on eth1, NAT so LAN uses eth0 for internet.
|
||||
# Run inside the LXC (as root), or from your machine: ./setup-network-boot-on-lxc.sh root@10.130.60.141
|
||||
# When run with ssh target, rsyncs lxc/ and runs this script inside the container.
|
||||
|
||||
set -e
|
||||
TARGET="${1:-}"
|
||||
|
||||
if [[ -n "$TARGET" ]]; then
|
||||
# Run remotely: sync lxc/ and script, then execute inside LXC
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
echo "Syncing lxc config and script to $TARGET ..."
|
||||
rsync -a "$REPO_DIR/lxc/" "$TARGET:/tmp/cm4-network-boot-lxc/" --exclude='.git'
|
||||
scp "$SCRIPT_DIR/setup-network-boot-on-lxc.sh" "$TARGET:/tmp/cm4-network-boot-lxc/setup.sh"
|
||||
ssh "$TARGET" "bash /tmp/cm4-network-boot-lxc/setup.sh"
|
||||
echo "Done."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Running inside the LXC from here ---
|
||||
echo "Configuring network boot (DHCP + TFTP on eth1, NAT via eth0) ..."
|
||||
|
||||
# 1) Install dnsmasq
|
||||
if ! command -v dnsmasq >/dev/null 2>&1; then
|
||||
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dnsmasq
|
||||
fi
|
||||
|
||||
# 2) dnsmasq config for eth1 only (DHCP + TFTP)
|
||||
mkdir -p /etc/dnsmasq.d
|
||||
cat > /etc/dnsmasq.d/network-boot.conf << 'DNSMASQ'
|
||||
# DHCP + TFTP on eth1 only (provisioning LAN)
|
||||
interface=eth1
|
||||
bind-interfaces
|
||||
dhcp-range=10.20.50.100,10.20.50.200,12h
|
||||
enable-tftp
|
||||
tftp-root=/srv/tftpboot
|
||||
dhcp-option=66,10.20.50.1
|
||||
dhcp-option=67,start4cd.elf
|
||||
log-dhcp
|
||||
log-queries
|
||||
port=0
|
||||
DNSMASQ
|
||||
|
||||
# 3) TFTP root: fetch Raspberry Pi 4 boot files from GitHub if missing
|
||||
mkdir -p /srv/tftpboot
|
||||
if [[ ! -f /srv/tftpboot/start4cd.elf ]]; then
|
||||
echo "Fetching Raspberry Pi firmware boot files from GitHub ..."
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl
|
||||
fi
|
||||
tmpdir=$(mktemp -d)
|
||||
trap "rm -rf $tmpdir" EXIT
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sL "https://github.com/raspberrypi/firmware/archive/refs/heads/master.tar.gz" -o "$tmpdir/firmware.tar.gz"
|
||||
else
|
||||
wget -q -O "$tmpdir/firmware.tar.gz" "https://github.com/raspberrypi/firmware/archive/refs/heads/master.tar.gz"
|
||||
fi
|
||||
tar xzf "$tmpdir/firmware.tar.gz" -C "$tmpdir"
|
||||
cp -a "$tmpdir/firmware-master/boot/." /srv/tftpboot/
|
||||
rm -rf "$tmpdir"
|
||||
echo "Copied RPi boot files to /srv/tftpboot"
|
||||
else
|
||||
echo "TFTP root already has boot files (start4cd.elf present), skipping fetch."
|
||||
fi
|
||||
|
||||
# 4) IP forwarding (LAN clients use WAN)
|
||||
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)
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
mkdir -p /etc/nftables.d
|
||||
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
|
||||
}
|
||||
}
|
||||
NFT
|
||||
if ! nft list table ip nat 2>/dev/null | grep -q postrouting; then
|
||||
nft -f /etc/nftables.d/nat-lan.conf
|
||||
fi
|
||||
# Ensure main config includes our drop-in (Debian)
|
||||
if [[ -f /etc/nftables.conf ]] && ! grep -q 'nftables.d/nat-lan' /etc/nftables.conf 2>/dev/null; then
|
||||
echo 'include "/etc/nftables.d/nat-lan.conf"' >> /etc/nftables.conf
|
||||
fi
|
||||
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
|
||||
echo "NAT rule added (iptables)."
|
||||
fi
|
||||
|
||||
# 6) Enable and start dnsmasq
|
||||
systemctl enable dnsmasq
|
||||
systemctl restart dnsmasq
|
||||
|
||||
echo "Network boot setup done."
|
||||
echo " - DHCP + TFTP on eth1 (10.20.50.1), range 10.20.50.100-200"
|
||||
echo " - NAT: 10.20.50.0/24 -> eth0 (internet)"
|
||||
echo " - TFTP root: /srv/tftpboot (RPi boot files from GitHub)"
|
||||
46
emmc-provisioning/scripts/sync-portal-files-to-lxc.sh
Executable file
46
emmc-provisioning/scripts/sync-portal-files-to-lxc.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync portal (file server) content from the repo to the LXC.
|
||||
# Updates /var/lib/cm4-provisioning/portal-files/ so first-boot and the
|
||||
# dashboard /files/ serve the same scripts and assets as in the repo.
|
||||
# Usage: ./sync-portal-files-to-lxc.sh [user@lxc_ip]
|
||||
# Example: ./sync-portal-files-to-lxc.sh root@10.130.60.141
|
||||
|
||||
set -e
|
||||
LXC="${1:-root@10.130.60.141}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
CLOUDINIT_DIR="$REPO_DIR/cloud-init"
|
||||
REMOTE_PORTAL="/var/lib/cm4-provisioning/portal-files"
|
||||
REMOTE_FIRST_BOOT="${REMOTE_PORTAL}/first-boot"
|
||||
|
||||
if [[ ! -d "$CLOUDINIT_DIR" ]]; then
|
||||
echo "Error: cloud-init dir not found: $CLOUDINIT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Syncing portal files to $LXC ($REMOTE_PORTAL) ..."
|
||||
ssh "$LXC" "command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y rsync)"
|
||||
ssh "$LXC" "mkdir -p $REMOTE_FIRST_BOOT"
|
||||
|
||||
# first-boot.sh at portal root (cloud-init downloads it by URL, not from first-boot/ subfolder)
|
||||
rsync -avz "$CLOUDINIT_DIR/first-boot.sh" "$LXC:$REMOTE_PORTAL/"
|
||||
|
||||
# config-files/* (includes chromium-kiosk.desktop) → portal-files/first-boot/
|
||||
rsync -avz --exclude='README.md' \
|
||||
"$CLOUDINIT_DIR/config-files/" \
|
||||
"$LXC:$REMOTE_FIRST_BOOT/"
|
||||
|
||||
# start-chromium.sh → portal-files/first-boot/
|
||||
rsync -avz "$CLOUDINIT_DIR/start-chromium.sh" "$LXC:$REMOTE_FIRST_BOOT/"
|
||||
|
||||
# plymouth-custom/* (custom.plymouth, custom.script, splash.png if present) → portal-files/first-boot/
|
||||
rsync -avz \
|
||||
"$CLOUDINIT_DIR/files-from-guard/plymouth-custom/" \
|
||||
"$LXC:$REMOTE_FIRST_BOOT/"
|
||||
|
||||
# one-shot scripts from cloud-init root → portal-files/first-boot/ (wallpaper set in first-boot via labwc autostart)
|
||||
rsync -avz \
|
||||
"$CLOUDINIT_DIR/set-rotation-once.sh" \
|
||||
"$LXC:$REMOTE_FIRST_BOOT/"
|
||||
|
||||
echo "Done. Portal files at http://$(echo "$LXC" | cut -d@ -f2):5000/files/"
|
||||
46
emmc-provisioning/scripts/troubleshoot-cloudinit-build.sh
Normal file
46
emmc-provisioning/scripts/troubleshoot-cloudinit-build.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox host (root@10.130.60.224) to diagnose cloud-init build failures.
|
||||
# Usage: scp this script to the host, then: bash troubleshoot-cloudinit-build.sh
|
||||
|
||||
set -e
|
||||
echo "=== Cloud-init build troubleshoot ==="
|
||||
echo ""
|
||||
|
||||
echo "1. xz available?"
|
||||
if command -v xz >/dev/null 2>&1; then
|
||||
xz --version | head -1
|
||||
else
|
||||
echo " NOT FOUND. Install: apt install -y xz-utils"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "2. /tmp space (need several GB for decompress)?"
|
||||
df -h /tmp
|
||||
echo ""
|
||||
|
||||
echo "3. Provisioning dir and last request?"
|
||||
PROV_DIR="${CM4_PROVISIONING_DIR:-/var/lib/cm4-provisioning}"
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
echo " PROV_DIR=$PROV_DIR"
|
||||
if [[ -f "$PROV_DIR/build_cloudinit_request.json" ]]; then
|
||||
echo " Last request URL: $(python3 -c "import json; print(json.load(open('$PROV_DIR/build_cloudinit_request.json')).get('url','?'))" 2>/dev/null || echo '?')"
|
||||
else
|
||||
echo " No request file (build may have already run)."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "4. Last build status?"
|
||||
if [[ -f "$PROV_DIR/build_cloudinit_status.json" ]]; then
|
||||
cat "$PROV_DIR/build_cloudinit_status.json" | python3 -m json.tool 2>/dev/null || cat "$PROV_DIR/build_cloudinit_status.json"
|
||||
else
|
||||
echo " No status file."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
TEST_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEST_DIR" EXIT
|
||||
echo "5. Quick xz decompress test?"
|
||||
echo "test content" > "$TEST_DIR/f.txt"
|
||||
xz -k "$TEST_DIR/f.txt" 2>/dev/null && xz -d -k -f "$TEST_DIR/f.txt.xz" 2>/dev/null && echo " OK" || echo " FAILED"
|
||||
echo ""
|
||||
echo "Done. If xz is missing, run on host: apt update && apt install -y xz-utils"
|
||||
Reference in New Issue
Block a user