Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3b534e51 | |||
| 011fe5a4c4 | |||
| 6f3f6ca596 | |||
| 5443b910c6 | |||
| a02957034d | |||
| ee61c24bb9 | |||
| 3a186d29df | |||
| 827445d514 |
@@ -560,6 +560,39 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
|
||||
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
|
||||
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
|
||||
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
|
||||
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
|
||||
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
|
||||
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
|
||||
const { readdir, readFile } = await import("node:fs/promises");
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
|
||||
} catch {
|
||||
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
|
||||
}
|
||||
const printers = await Promise.all(
|
||||
names.map(async (n) => {
|
||||
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
|
||||
let description: string | null = null;
|
||||
try {
|
||||
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
|
||||
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
|
||||
const mfg = pick("MFG") ?? pick("MANUFACTURER");
|
||||
const mdl = pick("MDL") ?? pick("MODEL");
|
||||
description = [mfg, mdl].filter(Boolean).join(" ") || null;
|
||||
} catch {
|
||||
/* sysfs not readable / attribute absent — path alone is still useful */
|
||||
}
|
||||
return { path: `/dev/usb/${n}`, description };
|
||||
}),
|
||||
);
|
||||
return { printers };
|
||||
});
|
||||
|
||||
// Assign a device. Validates the chosen driver + config, configures the device
|
||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type RelayEvent,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
fetchUsbPrinters,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
@@ -257,8 +258,10 @@ function CategorySection({
|
||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
// Binding categories need a controller to point at first. Printers do NOT bind
|
||||
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
|
||||
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
|
||||
const isBound = category !== "access" && category !== "printer";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||
|
||||
@@ -544,6 +547,28 @@ function DeviceForm({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
// USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
|
||||
// printer form is on the USB transport, so devicePath becomes a SELECT of real
|
||||
// devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
|
||||
const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
|
||||
const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
|
||||
useEffect(() => {
|
||||
if (!usbTransport) return;
|
||||
let alive = true;
|
||||
fetchUsbPrinters()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setUsbPrinters(r.printers);
|
||||
// Fresh form with no explicit path yet → preselect the first REAL device.
|
||||
if (r.printers.length > 0) {
|
||||
setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setUsbPrinters([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [usbTransport]);
|
||||
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||
@@ -888,7 +913,32 @@ function DeviceForm({
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
|
||||
// Real devices found → a select (path + self-reported model). A saved
|
||||
// path that is NOT currently present stays selectable, flagged.
|
||||
<select
|
||||
className="select"
|
||||
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, devicePath: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
|
||||
const missing = cur && !usbPrinters.some((u) => u.path === cur);
|
||||
return [
|
||||
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
|
||||
...usbPrinters,
|
||||
].map((u) => (
|
||||
<option key={u.path} value={u.path}>
|
||||
{u.description ? `${u.path} — ${u.description}` : u.path}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
) : f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
@@ -943,6 +993,9 @@ function DeviceForm({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
|
||||
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -604,6 +604,11 @@ export interface AssignBody {
|
||||
backendIp?: string;
|
||||
}
|
||||
|
||||
/** USB printers currently visible on the appliance (/dev/usb/lpN + sysfs model). */
|
||||
export function fetchUsbPrinters(): Promise<{ printers: { path: string; description: string | null }[] }> {
|
||||
return apiFetch("/api/setup/usb-printers");
|
||||
}
|
||||
|
||||
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
@@ -419,6 +419,8 @@ export const en: Catalog = {
|
||||
scan: "Scan for controllers",
|
||||
scanning: "Scanning…",
|
||||
noControllersFound: "No controllers found on the LAN.",
|
||||
usbNoneFound: "No USB printer found (/dev/usb/lpN) — check cable/power; the path can be typed manually.",
|
||||
usbSavedMissing: "saved — not present now",
|
||||
use: "Use",
|
||||
test: "Test connection",
|
||||
testing: "Testing…",
|
||||
|
||||
@@ -428,6 +428,8 @@ export const sq = {
|
||||
scan: "Skano për kontroller",
|
||||
scanning: "Duke skanuar…",
|
||||
noControllersFound: "Asnjë kontroller në LAN.",
|
||||
usbNoneFound: "Nuk u gjet asnjë printer USB (/dev/usb/lpN) — kontrollo kabllon/ushqimin; rruga mund të shkruhet me dorë.",
|
||||
usbSavedMissing: "i ruajtur — jo i pranishëm tani",
|
||||
use: "Përdor",
|
||||
test: "Testo lidhjen",
|
||||
testing: "Duke testuar…",
|
||||
|
||||
+37
-1
@@ -30,6 +30,42 @@
|
||||
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# park-lab — the LAB bench box (hardware/dev testing, no real traffic). Chases
|
||||
# the dev tier: compose files from `dev`, MOVING image tag `dev` (labs may
|
||||
# float; real booths pin). Secrets are its own park_lab_* refs — per-box blast
|
||||
# radius, never shared with a real booth even in the lab.
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-lab"
|
||||
[stack.config]
|
||||
server = "park-lab"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "dev"
|
||||
file_paths = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.prod.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Lab tier: the MOVING dev tag — redeploy pulls the latest dev build. Pin to a
|
||||
# dev-<sha> only when reproducing a specific state.
|
||||
TAG=dev
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
JWT_SECRET=[[park_lab_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
|
||||
BACKUP_KEY=[[park_lab_backup_key]]
|
||||
"""
|
||||
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-buzi"
|
||||
[stack.config]
|
||||
@@ -49,7 +85,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-14638c2
|
||||
TAG=stage-f9887c2
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
|
||||
@@ -185,11 +185,12 @@ describe("stamp (Albanian date format)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2026-07-06)", () => {
|
||||
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write.
|
||||
// The old single-write path dropped everything past the first buffer — the ICS
|
||||
// XP-K200L printed the ticket's text head but lost the barcode and the cut. A
|
||||
// regular file can't reproduce that, so these drive the loop with a fake handle.
|
||||
describe("writeAllUsb — partial writes / EAGAIN / close-cancel (field bugs 2026-07-06/07)", () => {
|
||||
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write,
|
||||
// write() returns at URB submission, and close() KILLS the in-flight URB — so the
|
||||
// loop must deliver every byte AND certify delivery before the caller may close
|
||||
// (final byte written alone; its acceptance proves all prior bytes landed). A
|
||||
// regular file can't reproduce any of that, so these drive a fake handle.
|
||||
|
||||
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
||||
function slowHandle(cap: number) {
|
||||
@@ -207,10 +208,18 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2
|
||||
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
||||
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||
const h = slowHandle(100); // way smaller than the job → many partial writes
|
||||
await writeAllUsb(h, payload, Date.now() + 2000);
|
||||
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("the FINAL byte is written alone — the delivery certificate before close", async () => {
|
||||
const payload = Buffer.from("x".repeat(5000)); // > one 4K chunk
|
||||
const h = slowHandle(100_000); // accepts anything → chunking is ours, not the cap's
|
||||
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||
expect(h.chunks.at(-1)!.length).toBe(1); // usblp: its acceptance proves the rest landed
|
||||
});
|
||||
|
||||
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
||||
const payload = Buffer.from("x".repeat(300));
|
||||
let calls = 0;
|
||||
@@ -228,7 +237,7 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2
|
||||
return Promise.resolve({ bytesWritten: n });
|
||||
},
|
||||
};
|
||||
await writeAllUsb(h, payload, Date.now() + 2000);
|
||||
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -604,6 +604,12 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
* so jobs are pushed in chunks safely under that. */
|
||||
const USB_WRITE_CHUNK = 4096;
|
||||
|
||||
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
|
||||
* proves everything before it is physically in the printer (see writeAllUsb); this
|
||||
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
|
||||
* immediately (it just freed buffer space by ACKing the previous chunk). */
|
||||
const USB_DRAIN_MS = 300;
|
||||
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
||||
@@ -613,31 +619,45 @@ export interface UsbWriteHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the WHOLE payload through a non-blocking usblp fd. On O_NONBLOCK the kernel
|
||||
* takes only what fits the printer's USB buffer and returns a SHORT write (or EAGAIN
|
||||
* when full) — a single fire-and-forget write() silently drops the tail of any job
|
||||
* bigger than one buffer. That was a real field bug (2026-07-06, ICS XP-K200L over
|
||||
* USB): the text head printed, but the barcode mid-payload and the CUT at the end
|
||||
* were in the dropped tail — "prints, but no barcode and no cut", while the same
|
||||
* bytes over TCP were fine. So: loop until every byte is accepted, retrying EAGAIN
|
||||
* and zero-byte writes with a short pause, bounded by the caller's deadline.
|
||||
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
|
||||
* physically received it before the caller may close. TWO field-verified truncation
|
||||
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
|
||||
* lost; TCP fine):
|
||||
*
|
||||
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
|
||||
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
|
||||
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
|
||||
* returns at URB *submission*, only ONE write URB is in flight at a time, and
|
||||
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
|
||||
* data at PRINT speed (tiny internal buffer), so closing right after the last
|
||||
* accepted write cancels the still-transferring tail — which is exactly where
|
||||
* the feed + GS V cut live ("have to press the feed button to see the text").
|
||||
*
|
||||
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
|
||||
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
|
||||
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
|
||||
* that 1-byte write is accepted, every byte before it is physically in the printer.
|
||||
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
|
||||
* close is safe. `drainMs` is parameterised only for tests.
|
||||
*/
|
||||
export async function writeAllUsb(
|
||||
handle: UsbWriteHandle,
|
||||
payload: Buffer,
|
||||
deadlineMs: number,
|
||||
drainMs: number = USB_DRAIN_MS,
|
||||
): Promise<void> {
|
||||
if (payload.length === 0) return;
|
||||
const lastByteAt = payload.length - 1;
|
||||
let off = 0;
|
||||
while (off < payload.length) {
|
||||
if (Date.now() > deadlineMs) {
|
||||
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
||||
}
|
||||
try {
|
||||
const { bytesWritten } = await handle.write(
|
||||
payload,
|
||||
off,
|
||||
Math.min(USB_WRITE_CHUNK, payload.length - off),
|
||||
);
|
||||
// Never let the final byte ride a bigger chunk: it is written ALONE so its
|
||||
// acceptance certifies delivery of everything before it (see doc above).
|
||||
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
|
||||
const { bytesWritten } = await handle.write(payload, off, len);
|
||||
off += bytesWritten;
|
||||
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
||||
} catch (err) {
|
||||
@@ -648,6 +668,9 @@ export async function writeAllUsb(
|
||||
}
|
||||
}
|
||||
}
|
||||
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
|
||||
// moment to land before the caller closes (close would cancel it).
|
||||
await delay(drainMs);
|
||||
}
|
||||
|
||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||
@@ -752,7 +775,7 @@ export const transportField: ConfigField = {
|
||||
default: "tcp-ip",
|
||||
options: [
|
||||
{ value: "tcp-ip", label: "Network (raw TCP)" },
|
||||
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
||||
{ value: "usb", label: "USB (local printer)" },
|
||||
],
|
||||
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||
};
|
||||
@@ -764,5 +787,5 @@ export const devicePathField: ConfigField = {
|
||||
type: "string",
|
||||
required: false,
|
||||
default: "/dev/usb/lp0",
|
||||
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
|
||||
help: "usblp character device (/dev/usb/lpN). The setup UI lists the printers actually present; the kernel numbers them (lp0, lp1, …) by plug/boot order. Only used when Connection is USB.",
|
||||
};
|
||||
|
||||
@@ -104,6 +104,36 @@ deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (
|
||||
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
||||
reproduce the char device's behaviour.
|
||||
|
||||
**Second truncation mode — close() cancels the in-flight transfer (lab bench, 2026-07-07).** The
|
||||
chunked loop alone STILL truncated on hardware (test slip stopped mid-sentence, no feed, no cut —
|
||||
"press the feed button to see the text"). Verified against `drivers/usb/class/usblp.c`: `write()`
|
||||
returns at URB *submission* (not completion), only ONE write URB is in flight at a time (the next
|
||||
write EAGAINs until it completes), and `usblp_release()` — i.e. our `close()` — **kills in-flight
|
||||
URBs**. The printer drains bulk data at PRINT speed (tiny internal buffer on these clones), so
|
||||
closing right after the last accepted write cancels the still-transferring tail — exactly where
|
||||
the feed + `GS V` cut bytes live. Kernel-accepted ≠ printer-received.
|
||||
|
||||
Fix: the one-URB rule makes acceptance of write N a **completion certificate for write N−1**. So
|
||||
`writeAllUsb` now writes the payload's FINAL BYTE alone: when that 1-byte write is accepted, every
|
||||
byte before it is physically in the printer; a short drain pause (`USB_DRAIN_MS` 300 ms) covers
|
||||
the lone final-byte packet, then close is safe. (usblp also implements `poll(POLLOUT)` as the true
|
||||
completion signal, but Node cannot poll an arbitrary char-device fd without a native dep — the
|
||||
hold-back + drain gets the same guarantee for all but the final byte, whose packet the printer
|
||||
ACKs immediately after having just freed its buffer.)
|
||||
|
||||
**✅ HARDWARE-VERIFIED (lab bench, 2026-07-07):** with both fixes, the ICS XP-K200L over USB
|
||||
prints the complete slip, feeds, and CUTS — parity with TCP. The USB transport is done.
|
||||
|
||||
**Device discovery (2026-07-07).** The kernel numbers usblp nodes by plug/boot order — park-buzi's
|
||||
printer is `lp1`, and the admin had to shell in and `ls /dev/usb` to learn that. The wizard now
|
||||
lists REAL printers: `GET /api/setup/usb-printers` enumerates `/dev/usb/lpN` (visible via the
|
||||
compose bind-mount) and enriches each with the printer's self-reported make/model from sysfs
|
||||
(`/sys/class/usbmisc/lpN/device/ieee1284_id` — readable through Docker's default ro `/sys`). The
|
||||
devicePath field becomes a SELECT ("/dev/usb/lp1 — Xprinter XP-K200L") with a fresh form
|
||||
preselecting the first present device; a saved-but-unplugged path stays selectable, flagged
|
||||
"saved — not present now"; zero devices found falls back to the free-text path + a check-the-cable
|
||||
hint. The transport option label no longer hardcodes lp0.
|
||||
|
||||
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
|
||||
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
|
||||
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
|
||||
|
||||
@@ -216,17 +216,24 @@ adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) an
|
||||
```bash
|
||||
sudo adduser admin && sudo usermod -aG sudo admin
|
||||
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
||||
sudo deluser <operator> sudo # demote the auto-login operator
|
||||
sudo gpasswd -d <operator> sudo # demote the auto-login operator
|
||||
groups <operator> # confirm: no 'sudo'
|
||||
```
|
||||
|
||||
> Use **`gpasswd -d`**, not `deluser <user> <group>`: on this Ubuntu the perl adduser tooling
|
||||
> rejects hyphenated usernames (`sanitize_string: invalid characters in 'park-operator'` —
|
||||
> VERIFIED on park-buzi 2026-07-06). And group removal applies at **next login** — the auto-login
|
||||
> operator session keeps its old memberships until the box reboots (or the session relogs);
|
||||
> re-verify `groups` from inside the operator session afterwards.
|
||||
|
||||
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
||||
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
||||
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
||||
|
||||
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
||||
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
|
||||
> Strip latent escalation groups from the operator: **`sudo gpasswd -d <operator> lxd`** (lxd group
|
||||
> = launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||
> `sudo gpasswd -d <operator> lpadmin` (printer admin, unneeded). And NEVER add the operator to
|
||||
> `docker` (also root-equivalent).
|
||||
|
||||
## 5b. Further hardening (TODO — not yet done)
|
||||
|
||||
@@ -280,8 +287,16 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
|
||||
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||
the proxy. (Gotcha #7 below.)
|
||||
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||
(singular); `root_directory` must be a path `admin` can write (user-mode default is fine — a
|
||||
`/etc/komodo` default from a system install would `Permission denied` for the user service).
|
||||
(singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install —
|
||||
Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`**
|
||||
(bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/
|
||||
periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart:
|
||||
```bash
|
||||
sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
||||
systemctl --user reset-failed periphery && systemctl --user restart periphery
|
||||
```
|
||||
NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
||||
`systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
||||
|
||||
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
||||
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||
|
||||
+46
@@ -2459,3 +2459,49 @@ stays attributable in the chain. Best-effort: no build/key → loud warning, see
|
||||
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
|
||||
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
|
||||
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
|
||||
|
||||
## [2026-07-06] update | Runbook §5c: gpasswd -d, not deluser (hyphenated-username perl bug)
|
||||
|
||||
Demoting the operator on park-buzi hit `sanitize_string: invalid characters in 'park-operator'` —
|
||||
Ubuntu's perl adduser/deluser tooling rejects the hyphenated username. [[appliance-provisioning]]
|
||||
§5c now uses `gpasswd -d <operator> sudo|lxd|lpadmin` (shadow-suite, no perl sanitize) and notes
|
||||
that group removal applies at NEXT login — the auto-login operator session keeps old memberships
|
||||
until reboot/relog, so verify `groups` from inside the session afterwards.
|
||||
|
||||
## [2026-07-07] update | Periphery v2.2.0 --user installer writes /etc/komodo root_directory
|
||||
|
||||
Lab test box (park-test): periphery crash-looped at startup — panic writing the private key to
|
||||
/etc/komodo/keys/periphery.key, Permission denied. Gotcha #9 was documented as a hand-config
|
||||
hazard, but v2.2.0's installer now DEFAULTS root_directory to /etc/komodo even with --user (the
|
||||
June park-buzi install defaulted under $HOME). [[appliance-provisioning]] §7a now says: verify
|
||||
root_directory after every install + the sed one-liner fix; also notes `sudo systemctl restart
|
||||
periphery` → "unit not found" (user unit) and that the single-use onboarding key survives a
|
||||
pre-connect crash.
|
||||
|
||||
## [2026-07-07] update | Setup: printers addable with NO controller configured
|
||||
|
||||
Lab bench (USB printer test, no relays on hand) hit a SECOND printer/relay coupling the
|
||||
2026-07-06 fix missed: the category section's add-button gate ("Shto fillimisht një kontroller…")
|
||||
blocks every non-access category while zero controllers exist. Printers are now exempt there too
|
||||
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
|
||||
A controller-less box can configure + test a printer.
|
||||
|
||||
## [2026-07-07] update | USB truncation, mode 2: close() kills the in-flight usblp URB
|
||||
|
||||
Lab hardware test of the chunked-write fix STILL truncated (slip stopped mid-sentence, no cut,
|
||||
text hidden until the feed button). Root cause verified against kernel usblp.c: write() returns at
|
||||
URB submission; one URB in flight; usblp_release (close) kills it; the printer drains at print
|
||||
speed — so the accepted-but-untransferred tail (incl. feed+cut, always the last bytes) died at
|
||||
close. writeAllUsb now holds back the FINAL byte as its own write — usblp's one-URB rule makes its
|
||||
acceptance a completion certificate for everything before it — then drains 300ms for that single
|
||||
packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
|
||||
the full kernel-level account.
|
||||
|
||||
## [2026-07-07] update | USB printing HARDWARE-VERIFIED; wizard lists real /dev/usb devices
|
||||
|
||||
Lab retest after the close-cancel fix: full slip + feed + CUT over USB — parity with TCP; the
|
||||
transport is done. Follow-up UX (operator had to `ls /dev/usb` to find lp1 on park-buzi): new
|
||||
GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/model; the wizard's
|
||||
devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
|
||||
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
|
||||
longer hardcodes lp0. Details on [[printer-usb-transport]].
|
||||
|
||||
Reference in New Issue
Block a user