Author SHA1 Message Date
Outis 7ed2d4744c Merge pull request #130 from outis1one/claude/optimistic-clarke-27gpk1
Claude/optimistic clarke 27gpk1
2026-11-06 11:04:55 -05:00
Outis 9b646b99f8 Merge pull request #129 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 11:04:28 -05:00
Claude a2811cced6 Virtual consoles: really enabled by default, and Upgrade no longer resets the choice
provision_configure_display always installed 10-serverflags.conf with
DontVTSwitch=true, so Ctrl+Alt+F1-F8 never worked on a fresh install
despite the installer saying consoles are enabled by default, and every
Upgrade overwrote what Advanced -> Virtual Consoles had set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:04:13 +00:00
Claude 07e7d2866f WiFi screen: reach the root helper through a socket instead of sudo; adapter dropdown
The kiosk app can end up with the no-new-privileges flag, after which sudo
refuses to run ("The no new privileges flag is set"). The helper is now
served over a root-owned, kiosk-group-only unix socket (systemd socket
activation); sudo stays as a fallback. With several WiFi adapters the screen
shows a dropdown and lists all of them; status/scan/connect take an iface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:00:10 +00:00
Claude 0fe631b4ed WiFi helper: act on the connected adapter, not the first one alphabetically
With a built-in card plus a USB adapter, status reported 'Not connected'
for the idle built-in card while the USB adapter carried the connection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 15:55:03 +00:00
27 changed files with 421 additions and 2657 deletions
+3 -1
View File
@@ -537,7 +537,9 @@ It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kios
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out. **About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade. **More than one WiFi adapter** (e.g. a built-in card and a USB dongle): a *WiFi adapter* dropdown appears at the top, and the status box lists every adapter with what it's connected to. Status, scan and connect act on the one you pick; by default it's the adapter that's connected right now.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect and region only). The app reaches it through a root-owned socket, `/run/kiosk-wifi-helper.sock` (`kiosk-wifi-helper.socket`, group-restricted to the kiosk user, `0660`), rather than `sudo`: the app can end up running with Linux's "no new privileges" flag, and then sudo refuses with *"The no new privileges flag is set"*. A passwordless-sudo rule (`/etc/sudoers.d/kiosk-wifi`) stays as a fallback. Existing kiosks get both with Advanced → Upgrade.
--- ---
+65 -7
View File
@@ -1,5 +1,6 @@
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron'); const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
const {exec,spawn}=require('child_process'); const {exec,spawn}=require('child_process');
const net=require('net');
const fs=require('fs'); const fs=require('fs');
const path=require('path'); const path=require('path');
const os=require('os'); const os=require('os');
@@ -1215,7 +1216,56 @@ const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null; let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
function runWifiHelper(action,input){ const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
// Parse the helper's one JSON reply line (the last line of its output).
function parseWifiReply(out){
const line=out.trim().split('\n').pop();
return JSON.parse(line);
}
// Preferred route: a root-owned socket (kiosk-wifi-helper.socket). Unlike
// sudo it keeps working when this app has the "no new privileges" flag set.
// Resolves null when the socket isn't usable (not installed yet, or this
// user isn't allowed), so the caller falls back to sudo.
function runWifiHelperSocket(action,input){
return new Promise(resolve=>{
let out='',done=false;
const finish=result=>{if(!done){done=true;clearTimeout(killer);resolve(result);}};
const sock=net.connect({path:WIFI_SOCKET,allowHalfOpen:true});
const killer=setTimeout(()=>{sock.destroy();finish({ok:false,error:'The WiFi helper timed out'});},120000);
sock.on('connect',()=>{
// Request = action line, then the JSON body; half-close so the
// helper sees end of input.
sock.end(action+'\n'+(input?JSON.stringify(input):''));
});
sock.on('data',d=>{out+=d;});
sock.on('error',e=>{
if(out){
// A reset after the reply arrived - use the reply.
try{finish(parseWifiReply(out));return;}catch(_){}
}
if(!out&&['ENOENT','ECONNREFUSED','EACCES','EPERM'].includes(e.code)){
console.log('[WIFI] Helper socket unavailable ('+e.code+') - falling back to sudo');
finish(null);
}else{
finish({ok:false,error:'Could not reach the WiFi helper: '+e.message});
}
});
sock.on('close',()=>{
if(done)return;
try{
finish(parseWifiReply(out));
}catch(e){
console.error('[WIFI] helper socket output:',out);
finish({ok:false,error:'The WiFi helper gave no usable answer'});
}
});
});
}
// Fallback: the helper through passwordless sudo (/etc/sudoers.d/kiosk-wifi).
function runWifiHelperSudo(action,input){
return new Promise(resolve=>{ return new Promise(resolve=>{
let out='',err='',done=false; let out='',err='',done=false;
const finish=result=>{if(!done){done=true;resolve(result);}}; const finish=result=>{if(!done){done=true;resolve(result);}};
@@ -1232,12 +1282,11 @@ function runWifiHelper(action,input){
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});}); child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
child.on('close',()=>{ child.on('close',()=>{
clearTimeout(killer); clearTimeout(killer);
const line=out.trim().split('\n').pop();
try{ try{
finish(JSON.parse(line)); finish(parseWifiReply(out));
}catch(e){ }catch(e){
// Not our JSON - typically sudo refusing ("a password is required") // Not our JSON - typically sudo refusing ("a password is required",
// on a kiosk set up before this feature: Upgrade installs the rule. // or the "no new privileges" flag) - Upgrade installs the socket.
console.error('[WIFI] helper output:',out,err); console.error('[WIFI] helper output:',out,err);
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'}); finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
} }
@@ -1247,6 +1296,12 @@ function runWifiHelper(action,input){
}); });
} }
async function runWifiHelper(action,input){
const viaSocket=await runWifiHelperSocket(action,input);
if(viaSocket!==null)return viaSocket;
return runWifiHelperSudo(action,input);
}
function resetWifiIdleTimer(){ function resetWifiIdleTimer(){
if(wifiIdleTimer)clearTimeout(wifiIdleTimer); if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
wifiIdleTimer=setTimeout(()=>{ wifiIdleTimer=setTimeout(()=>{
@@ -1327,7 +1382,10 @@ ipcMain.handle('wifi-request',async(event,req)=>{
return result; return result;
} }
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'}; if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='status'||action==='scan')return runWifiHelper(action); if(action==='ifaces')return runWifiHelper('ifaces');
if(action==='status'||action==='scan'){
return runWifiHelper(action,req.iface?{iface:String(req.iface)}:null);
}
if(action==='region-status')return runWifiHelper('region-status'); if(action==='region-status')return runWifiHelper('region-status');
if(action==='region-set'){ if(action==='region-set'){
const input={}; const input={};
@@ -1337,7 +1395,7 @@ ipcMain.handle('wifi-request',async(event,req)=>{
} }
if(action==='connect'){ if(action==='connect'){
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||''))); console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')}); const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||''),iface:req.iface?String(req.iface):''});
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error); console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
return result; return result;
} }
+48 -7
View File
@@ -95,7 +95,11 @@
</div> </div>
<div id="wifi-step" class="hidden"> <div id="wifi-step" class="hidden">
<div id="status">Checking…</div> <div id="adapter-row" class="hidden">
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
<select id="adapter"></select>
</div>
<div id="status" style="margin-top: 12px">Checking…</div>
<div class="row" style="margin-top: 14px"> <div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label> <label style="margin: 0; flex: 1">Networks nearby</label>
@@ -139,6 +143,7 @@
const {ipcRenderer} = require('electron'); const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let busy = false; let busy = false;
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
function request(action, extra) { function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {})) return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
@@ -163,16 +168,47 @@
return '▂'; return '▂';
} }
function adapterLabel(a) {
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
}
// Fill the dropdown from the helper's adapter list. Shown only when
// there is more than one adapter; the choice sticks across refreshes.
function fillAdapters(r) {
const row = $('adapter-row');
const sel = $('adapter');
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
const names = r.adapters.map(a => a.name);
if (!names.includes(adapter)) adapter = r.selected || names[0];
sel.textContent = '';
for (const a of r.adapters) {
const o = document.createElement('option');
o.value = a.name;
o.textContent = adapterLabel(a);
if (a.name === adapter) o.selected = true;
sel.appendChild(o);
}
row.classList.toggle('hidden', r.adapters.length < 2);
return r;
}
async function refreshStatus() { async function refreshStatus() {
const s = await request('status'); const ad = fillAdapters(await request('ifaces'));
const s = await request('status', adapter ? {iface: adapter} : {});
const box = $('status'); const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; } if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; } if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = ''; box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); }; const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
// Every adapter at a glance, the selected one marked.
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
} else {
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected'); line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
line(`Adapter: ${s.iface}`); line(`Adapter: ${s.iface}`);
}
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured; if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
} }
@@ -181,7 +217,7 @@
setBusy(true); setBusy(true);
message('Scanning…', 'busy'); message('Scanning…', 'busy');
const list = $('networks'); const list = $('networks');
const r = await request('scan'); const r = await request('scan', adapter ? {iface: adapter} : {});
list.textContent = ''; list.textContent = '';
if (!r.ok) { if (!r.ok) {
message(r.error, 'error'); message(r.error, 'error');
@@ -217,7 +253,7 @@
if (!ssid) { message('Pick a network or type its name', 'error'); return; } if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true); setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy'); message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password}); const r = await request('connect', {ssid, password, iface: adapter});
if (r.ok) { if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok'); message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = ''; $('password').value = '';
@@ -287,14 +323,19 @@
message(''); message('');
$('pin-step').classList.add('hidden'); $('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden'); $('wifi-step').classList.remove('hidden');
refreshStatus(); refreshStatus().then(() => scan());
loadRegion(); loadRegion();
scan();
} }
$('pin-ok').addEventListener('click', unlock); $('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); }); $('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan); $('scan').addEventListener('click', scan);
$('adapter').addEventListener('change', () => {
adapter = $('adapter').value;
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
refreshStatus();
scan();
});
$('region-save').addEventListener('click', saveRegion); $('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale); $('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect); $('connect').addEventListener('click', connect);
+22
View File
@@ -198,7 +198,12 @@ provision_configure_display() {
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can # a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
# flip this later, and does below if declined at the end of setup. # flip this later, and does below if declined at the end of setup.
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
# Only on first install: this file is also where Advanced -> Virtual
# Consoles records the admin's choice, and Upgrade re-runs this step -
# overwriting it would silently turn consoles back off (or on).
if [[ ! -f /etc/X11/xorg.conf.d/10-serverflags.conf ]]; then
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
fi
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755 provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755 provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
@@ -242,6 +247,23 @@ provision_install_wifi_helper() {
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect" log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
fi fi
rm -f "$tmp" rm -f "$tmp"
# The same helper through a root-owned socket, which is what the kiosk
# app uses first: it can end up running with the "no new privileges"
# flag, and sudo then refuses to run as root. The sudoers rule above
# stays as a fallback. Socket access is limited to the kiosk user's
# group (0660) - the same reach as the sudoers rule.
provision_install_file "usr/local/bin/kiosk-wifi-helper-socket" "$BIN_DIR/kiosk-wifi-helper-socket" 755
provision_install_file "etc/systemd/system/kiosk-wifi-helper@.service" "$SYSTEMD_DIR/kiosk-wifi-helper@.service"
provision_install_file "etc/systemd/system/kiosk-wifi-helper.socket" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
local kiosk_group
kiosk_group=$(id -gn "$KIOSK_USER" 2>/dev/null || echo "$KIOSK_USER")
sudo sed -i "s/@KIOSK_GROUP@/${kiosk_group}/" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
sudo systemctl daemon-reload
if ! sudo systemctl enable kiosk-wifi-helper.socket &>/dev/null || ! sudo systemctl restart kiosk-wifi-helper.socket; then
log_warning "Couldn't start the kiosk WiFi helper socket - the WiFi screen will fall back to sudo"
fi
# Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off. # Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off.
provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules
sudo udevadm control --reload-rules 2>/dev/null || true sudo udevadm control --reload-rules 2>/dev/null || true
-3
View File
@@ -3,6 +3,3 @@
# Extra CA certificates dropped in for building behind a proxy # Extra CA certificates dropped in for building behind a proxy
koha/certs/*.crt koha/certs/*.crt
quickadd/certs/*.crt quickadd/certs/*.crt
# Generated by install.sh --domain
Caddyfile
caddy-root.crt
+5 -123
View File
@@ -1,4 +1,4 @@
# Room for Books: a home library (Koha + the Room for Books site, in Docker) # Home library: Koha + Quick Add (Docker)
A self-hosted library catalogue you can set up in one command: A self-hosted library catalogue you can set up in one command:
@@ -12,19 +12,12 @@ it never overwrites your `.env` (passwords) or your data.
| What | Address | Login | | What | Address | Login |
|---|---|---| |---|---|---|
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8098` | `admin` and the generated password | | **Catalogue** (public search, no login) | `http://<this machine>:8080` | none |
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8098/add/` | same login (asked once more) | | **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password |
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8097` | none | | **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login |
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
Ports move up to the next free number if one is taken; the final addresses are printed Ports move up to the next free number if one is taken; the final addresses are printed
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port at the end and by `sudo ./install.sh --status`.
(`8099`), which is handy behind a proxy but not needed otherwise.
## What it is made of ## What it is made of
@@ -203,114 +196,3 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
servers, label templates/layouts) servers, label templates/layouts)
quickadd/ the scan-to-add service (Python standard library + yaz) quickadd/ the scan-to-add service (Python standard library + yaz)
``` ```
## Signing in once
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
## The home page (the Room for Books site at `/add/`)
**One address.** `http://<ip>:<port>/` (and later `https://your-domain/` behind Caddy) opens this page; there is no separate
port to remember. Koha's own staff screens live on the same address at `/cgi-bin/koha/mainpage.pl`, reached from the menu
(*Koha (full staff screens)*). Everyone signs in at the same prompt: staff with the admin login, borrowers with their card number
and password, the guest with the guest login.
One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons
(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu**
(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import,
and a link to Koha's own staff screens. Borrowers and the read-only guest see just the lookup, recent books and (borrowers) My books.
**Adding by title:** on Add books, scan or type an ISBN to add it straight away; type a title or author instead and you get a list
of matches (cover, author, year, publisher) from Open Library and the Library of Congress to choose from. Books you already have
are marked, with an *Add another copy* button.
## Everyday use (details)
One page for the day-to-day, so you rarely need Koha's own screens:
- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
(book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
"Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
- **People**: add someone with just a name (the card number is assigned automatically), search, and
**Print card** for a library card with a scannable Code 39 barcode.
- **Add books**: scan an ISBN (or use the phone camera), as before.
The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending).
### Collections, QR cards, more about a book
- **Collections** (Kids, Cookbooks, ...) are per copy, shown on the book page next to the shelf, and filterable on the
Library page. Choose **+ New collection...** (or **+ New shelf...**) in the dropdown to add one: no Koha screens needed.
- **Library cards** carry a Code 39 barcode and a **QR code** of the card number. **Save as picture** makes an image to text
or AirDrop to a phone; on Lend / return, **Scan card or phone QR with camera** reads either one from a photo.
**Print everyone's cards** (People) lays out a sheet of cards.
- **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing,
WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it.
### Reading levels (AR) and spreadsheets
AR BookFind has no public feed or import, and its terms forbid automated look-ups, so this site does not fetch from it.
Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to paste into its search) and boxes to record the
**level, points and quiz number** you find there (the read-only guest sees them). **Library page -> Export / import** downloads
`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
### Live camera scanning (needs HTTPS)
Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real
certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or
phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other
browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay.
### Copying reading levels from AR BookFind
On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level,
points, quiz number and interest level fill in by themselves; check them and press Save.
### Tags, and borrowers signing in
- **Tags**: any number per book (to read, kids, signed...). Add and remove them on the book page; click one to see every
book that has it; the Library page can filter by tag. They are stored in `tags.json` in the Add books data volume, which
the usual backups of that folder include.
- **Borrowers sign in**: every person gets a login, **their library card number + a password**. Adding someone shows
their password once (a different easy one like `maple482`, or the same one for everyone: People -> *Sign-in passwords for new
people*). *Give everyone a sign-in* covers people added earlier; *New password* resets one person. A borrower who signs in
sees the library and a **My books** page: what they have out, when each is due (or overdue), **Renew**, and **Change my
password**. They cannot lend, edit or see other people. Passwords are Koha's own patron passwords; this stack relaxes Koha's
default strength rule (4+ characters instead of 8 with upper/lower/digit) once, at install or update.
Ten wrong guesses from one address locks sign-in attempts from it for five minutes.
### Reading levels: pop-up or in-page AR BookFind
The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
it into AR's search; AR's own page cannot be pre-filled from here.
### Same ports on every install
New installs of this stack use the same three ports whether you install it from the kiosk add-on or from `ubuntu-post-install`:
**8098** is the one to use (the Room for Books home page, with Koha behind the menu), 8099 is Add books direct, and 8097 is the
public catalogue, which stays closed. If one is taken, the next free port is used and the installer prints it. Existing installs
keep the ports they already have.
## HTTPS with Caddy (and the live camera scanner)
Phones only allow a live camera on an HTTPS page. One command adds Caddy in front of the library:
```bash
# a real domain that points at this machine (ports 80 and 443 reachable): the certificate is automatic
sudo ./install.sh --domain library.example.com
# a name only your own network knows (library.home.lan): Caddy makes its own certificate authority
sudo ./install.sh --domain library.home.lan --lan-tls # then install caddy-root.crt on your phones
```
It writes `Caddyfile`, starts a `caddy` container (the compose `caddy` profile), sets `STAFF_URL=https://<name>` and closes the plain-HTTP
ports to the network (`BIND_ADDR=127.0.0.1`; add `--keep-direct` to leave them open). Then use `https://<name>/`: everything works
as before, and the **Live scan** buttons appear on Add books and Lend / return. Behind your own Caddy instead, point it at the library
port: `library.example.com { reverse_proxy <this machine>:8098 }` and set `STAFF_URL=https://library.example.com` in `.env`.
+8 -32
View File
@@ -30,12 +30,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
quickadd:
condition: service_started
environment: environment:
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
KOHA_QUICKADD_URL: http://quickadd:8082
KOHA_SSO_SECRET: ${SSO_SECRET:-}
KOHA_DB_HOST: db KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD} KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin} KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
@@ -45,9 +41,8 @@ services:
KOHA_SHELVES: ${SHELVES:-} KOHA_SHELVES: ${SHELVES:-}
KOHA_ITEM_TYPES: ${ITEM_TYPES:-} KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports: ports:
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it. - "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8097}:80" - "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8098}:8081"
volumes: volumes:
- koha-data:/var/lib/koha - koha-data:/var/lib/koha
@@ -58,45 +53,26 @@ services:
image: library-quickadd:local image: library-quickadd:local
container_name: library-quickadd container_name: library-quickadd
restart: unless-stopped restart: unless-stopped
depends_on:
koha:
condition: service_started
environment: environment:
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
KOHA_API: http://koha:8081/api/v1 KOHA_API: http://koha:8081/api/v1
SSO_SECRET: ${SSO_SECRET:-}
ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VIEWER_USER: ${VIEWER_USER:-guest}
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME} KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8097} OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8098} STAFF_PORT: ${STAFF_PORT:-8081}
OPAC_URL: ${OPAC_URL:-} OPAC_URL: ${OPAC_URL:-}
STAFF_URL: ${STAFF_URL:-} STAFF_URL: ${STAFF_URL:-}
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
ports: ports:
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8099}:8082" - "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082"
volumes: volumes:
- quickadd-data:/data - quickadd-data:/data
# HTTPS with an automatic certificate (and so the live camera on phones): only started when
# `./install.sh --domain <name>` has set COMPOSE_PROFILES=caddy in .env.
caddy:
profiles: ["caddy"]
image: caddy:2
container_name: library-caddy
restart: unless-stopped
depends_on:
- koha
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes: volumes:
db-data: db-data:
koha-data: koha-data:
quickadd-data: quickadd-data:
caddy-data:
caddy-config:
+10 -88
View File
@@ -5,16 +5,13 @@
# sudo ./install.sh --status # show URLs and container health # sudo ./install.sh --status # show URLs and container health
# sudo ./install.sh --update # rebuild with the latest Koha and restart # sudo ./install.sh --update # rebuild with the latest Koha and restart
# sudo ./install.sh --stop | --start # sudo ./install.sh --stop | --start
# sudo ./install.sh --domain library.example.com # HTTPS via Caddy (certificate is automatic)
# sudo ./install.sh --domain library.home.lan --lan-tls # HTTPS for a name only your network knows (own CA)
# add --keep-direct to also keep the plain-HTTP ports open on the network
# #
# Optional environment variables: # Optional environment variables:
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue # LIBRARY_NAME="Smith Family Library" name shown on the catalogue
# LIBRARY_CODE=HOME short code for the library (letters/digits) # LIBRARY_CODE=HOME short code for the library (letters/digits)
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only) # SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
# ITEM_TYPES="CD=Compact disc" extra item types (first install only) # ITEM_TYPES="CD=Compact disc" extra item types (first install only)
# OPAC_PORT=8097 STAFF_PORT=8098 preferred host ports (next free is used) # OPAC_PORT=8080 STAFF_PORT=8081 preferred host ports (next free is used)
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building # EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
set -euo pipefail set -euo pipefail
@@ -82,67 +79,13 @@ show_status() {
fi fi
dc ps dc ps
echo echo
if [ -n "${DOMAIN:-}" ]; then echo " Catalogue (public): http://$ip:$OPAC_PORT"
echo " Your library (HTTPS): https://$DOMAIN <- use this one: the live camera scanner needs it" echo " Staff interface: http://$ip:$STAFF_PORT"
echo " (Koha's own screens: https://$DOMAIN/cgi-bin/koha/mainpage.pl, or the menu inside)" echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)"
fi echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
echo " Your library: http://$ip:$STAFF_PORT"
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
echo " It opens the Find a book page: search only, it cannot add or change anything."
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
fi
echo echo
} }
# --- HTTPS (Caddy) options --------------------------------------------------------
DOMAIN_ARG="" LAN_TLS=0 KEEP_DIRECT=0 ACTION=""
while [ $# -gt 0 ]; do
case "$1" in
--domain) DOMAIN_ARG="${2:-}"; [ -n "$DOMAIN_ARG" ] || { c_err "--domain needs a name"; exit 1; }; shift 2 ;;
--lan-tls) LAN_TLS=1; shift ;;
--keep-direct) KEEP_DIRECT=1; shift ;;
*) ACTION="${ACTION:+$ACTION }$1"; shift ;;
esac
done
set -- $ACTION
if [ -n "$DOMAIN_ARG" ] && ! printf '%s' "$DOMAIN_ARG" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$'; then
c_err "That does not look like a host name: $DOMAIN_ARG"; exit 1
fi
# Write (or replace) KEY=VALUE in .env
env_set() { if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi; }
configure_caddy() {
local name="$DOMAIN_ARG"
# Caddy needs 80 and 443 (it is not moved to other ports: browsers expect these).
if ! dc ps --status running --services 2>/dev/null | grep -qx caddy; then
for p in 80 443; do
port_in_use "$p" && { c_err "Port $p is already in use on this machine - Caddy needs it. Stop what is using it, or use your own reverse proxy (see README)."; exit 1; }
done
fi
{
echo "# Generated by install.sh --domain - Caddy fetches and renews the certificate by itself."
echo "$name {"
[ "$LAN_TLS" = 1 ] && echo " tls internal"
echo " encode zstd gzip"
echo " reverse_proxy koha:8081"
echo "}"
} > Caddyfile
env_set DOMAIN "$name"
env_set COMPOSE_PROFILES caddy
env_set STAFF_URL "https://$name"
[ "$KEEP_DIRECT" = 1 ] || env_set BIND_ADDR 127.0.0.1
c_ok "Caddy will serve https://$name"
if [ "$LAN_TLS" = 1 ]; then
c_info "Own-CA certificate: phones must trust Caddy's root, which is copied to $HERE/caddy-root.crt after start."
else
c_info "The name must point at this machine (DNS) and ports 80/443 must reach it, or the certificate cannot be issued."
fi
}
case "${1:-}" in case "${1:-}" in
--status) show_status; exit 0 ;; --status) show_status; exit 0 ;;
--stop) dc stop; exit 0 ;; --stop) dc stop; exit 0 ;;
@@ -170,18 +113,12 @@ LIBRARY_CODE=${LIBRARY_CODE:-HOME}
SHELVES="${SHELVES:-}" SHELVES="${SHELVES:-}"
ITEM_TYPES="${ITEM_TYPES:-}" ITEM_TYPES="${ITEM_TYPES:-}"
TZ=${TZ_NOW} TZ=${TZ_NOW}
OPAC_PORT=$(free_port "${OPAC_PORT:-8097}") OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8098}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8099}") QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
BIND_ADDR=${BIND_ADDR:-0.0.0.0} BIND_ADDR=${BIND_ADDR:-0.0.0.0}
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=$(rand 16) ADMIN_PASSWORD=$(rand 16)
VIEWER_USER=guest
VIEWER_PASSWORD=$(rand 14)
# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
SSO_SECRET=$(rand 40)
DB_ROOT_PASSWORD=$(rand 32) DB_ROOT_PASSWORD=$(rand 32)
DB_PASSWORD=$(rand 32) DB_PASSWORD=$(rand 32)
KOHA_SUITE=stable KOHA_SUITE=stable
@@ -192,21 +129,13 @@ else
c_info "Re-using existing .env" c_info "Re-using existing .env"
# Settings added in later versions: append them to an older .env. # Settings added in later versions: append them to an older .env.
if ! grep -q '^QUICKADD_PORT=' .env; then if ! grep -q '^QUICKADD_PORT=' .env; then
echo "QUICKADD_PORT=$(free_port 8099)" >> .env echo "QUICKADD_PORT=$(free_port 8082)" >> .env
fi fi
if ! grep -q '^VIEWER_PASSWORD=' .env; then
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
fi
grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
# Existing installs keep their current public-catalogue behaviour.
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
if ! grep -q '^BIND_ADDR=' .env; then if ! grep -q '^BIND_ADDR=' .env; then
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
fi fi
fi fi
[ -n "$DOMAIN_ARG" ] && configure_caddy
if [ -n "${EXTRA_CA:-}" ]; then if [ -n "${EXTRA_CA:-}" ]; then
cp "$EXTRA_CA" koha/certs/extra-ca.crt cp "$EXTRA_CA" koha/certs/extra-ca.crt
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
@@ -225,7 +154,7 @@ with_retry dc up -d
c_info "First start installs the database - waiting until Koha reports healthy..." c_info "First start installs the database - waiting until Koha reports healthy..."
for i in $(seq 1 180); do for i in $(seq 1 180); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)" health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)"
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8099}/healthz" 2>/dev/null; then READY=1; break; fi if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8082}/healthz" 2>/dev/null; then READY=1; break; fi
# One restart is tolerated (Docker's restart policy); a crash loop is not. # One restart is tolerated (Docker's restart policy); a crash loop is not.
if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1 c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
@@ -235,12 +164,5 @@ done
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; } [ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
c_ok "Your library is running." c_ok "Your library is running."
if [ -n "${DOMAIN:-}" ] && grep -q '^ tls internal' Caddyfile 2>/dev/null; then
for i in $(seq 1 20); do
docker cp library-caddy:/data/caddy/pki/authorities/local/root.crt "$HERE/caddy-root.crt" >/dev/null 2>&1 && break
sleep 2
done
[ -f "$HERE/caddy-root.crt" ] && c_info "Install this certificate on phones/computers that should trust https://$DOMAIN: $HERE/caddy-root.crt"
fi
echo echo
show_status show_status
+1 -7
View File
@@ -39,13 +39,7 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \ command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
done done
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header). COPY bootstrap.pl entrypoint.sh /opt/koha-init/
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
COPY seed/ /opt/koha-init/seed/ COPY seed/ /opt/koha-init/seed/
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
+2 -2
View File
@@ -160,11 +160,11 @@ my %prefs = (
finesMode => 'off', # a home library does not charge overdue fines finesMode => 'off', # a home library does not charge overdue fines
itemcallnumber => '082ab', # Dewey call number copied from the MARC record itemcallnumber => '082ab', # Dewey call number copied from the MARC record
RESTBasicAuth => 1, # lets the quick-add page use the REST API RESTBasicAuth => 1, # lets the quick-add page use the REST API
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
OpenLibraryCovers => 1, # cover images in the catalogue OpenLibraryCovers => 1, # cover images in the catalogue
OPACSearchForTitleIn => '', OPACSearchForTitleIn => '',
BiblioDefaultView => 'normal', BiblioDefaultView => 'normal',
opacuserlogin => 1, OpacPublic => 1,
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
OpacMaintenance => 0, OpacMaintenance => 0,
ExpireReservesMaxPickUpDelay => 0, ExpireReservesMaxPickUpDelay => 0,
AllowOfflineCirculation => 0, AllowOfflineCirculation => 0,
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/perl
# Idempotent housekeeping, run on EVERY container start (after the first-time install or
# the schema upgrade), so existing installs pick these up too:
# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only)
# * the "Library tools" block on the staff home page
use Modern::Perl;
use C4::Context;
use Koha::Patrons;
use Koha::Patron::Categories;
use Koha::Caches;
my $dbh = C4::Context->dbh;
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
# Koha's own "catalogue" permission also allows editing label layouts and templates, so a
# Koha account cannot be made truly read-only. Remove any guest account an earlier version created.
for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) {
say "ensure: removing the old Koha guest account '" . $p->userid . "'";
$p->delete;
}
if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
}
# Patron logins use short, easy-to-remember passwords (a home library, not a bank): Koha's defaults
# (8+ characters, upper+lower+digit) are relaxed once, unless an admin has changed them since.
my $flag = '/var/lib/koha/' . ( $ENV{KOHA_INSTANCE} || 'library' ) . '/.password-policy-set';
unless ( -e $flag ) {
my ($strong) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'RequireStrongPassword'});
my ($minlen) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'minPasswordLength'});
if ( ( $strong // 1 ) == 1 && ( $minlen // 8 ) == 8 ) {
$dbh->do(q{UPDATE systempreferences SET value = 0 WHERE variable = 'RequireStrongPassword'});
$dbh->do(q{UPDATE systempreferences SET value = 4 WHERE variable = 'minPasswordLength'});
say "ensure: patron passwords may be short (4+ characters, no strength rule)";
Koha::Caches->get_instance->flush_all; # preferences are cached; direct SQL edits need this
}
if ( open my $fh, '>', $flag ) { close $fh }
}
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). Rewritten each start.
my $html = '<div class="main-heading"><h3>Room for Books</h3></div><ul class="list-unstyled">'
. '<li><a class="btn btn-primary" href="/add/">Open Room for Books</a> (library, lend / return, people, add books)</li>'
. '<li><a href="/add/#find">Find a book</a></li></ul>';
my ($id) = $dbh->selectrow_array(q{SELECT id FROM additional_contents WHERE code = 'library_tools'});
unless ($id) {
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
$id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
VALUES (?, 'Room for Books', ?, 'default')}, undef, $id, $html );
say "ensure: added the staff home 'Room for Books' block";
}
else {
$dbh->do( q{UPDATE additional_contents_localizations SET title = 'Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'},
undef, $html, $id );
}
+4 -40
View File
@@ -56,43 +56,12 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE" -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
# One address: serve the Add books page (Quick Add) inside the staff site at /add/. # Run Apache itself as the instance user instead of mpm_itk's per-vhost
# (The staff and public catalogue sites cannot share one address: Koha gives both the # uid switching (which needs extra capabilities and is pointless here:
# same URL shapes. The staff site is the natural home for a home library.) # Plack does the real work as that user already).
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
# Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a
# secret header nobody outside can forge: it is overwritten here on every request) that the
# request came through the staff site, and Quick Add then asks Koha whether the browser's login
# cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything
# about that fails, Quick Add just asks for its own login as before.
SSO_BLOCK=""
if [ -n "${KOHA_SSO_SECRET:-}" ]; then
install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret
printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret
SSO_BLOCK=" <IfModule mod_headers.c>
<Location /add/>
RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
</Location>
</IfModule>"
fi
cat > /etc/koha/apache-quickadd.conf << APACHE
<IfModule mod_proxy_http.c>
RedirectMatch ^/add\$ /add/
# The bare address (http://ip:port/ or https://your-domain/) opens the Room for Books home page;
# Koha's own staff screens stay at /cgi-bin/koha/mainpage.pl.
RedirectMatch ^/\$ /add/
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
${SSO_BLOCK}
</IfModule>
APACHE
sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf"
fi
# koha-create insists on mpm_itk being enabled, but inside a container it can # koha-create insists on mpm_itk being enabled, but inside a container it can
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every # not switch users (initgroups: Operation not permitted -> HTTP 500 on every
# page). Now that the instance exists, swap to plain prefork; Apache runs as the # page). Now that the instance exists, swap to plain prefork.
# instance user (Plack does the real work as that user already).
rm -f /etc/apache2/mods-enabled/mpm_itk.* rm -f /etc/apache2/mods-enabled/mpm_itk.*
a2enmod mpm_prefork >/dev/null 2>&1 || true a2enmod mpm_prefork >/dev/null 2>&1 || true
sed -i '/AssignUserID/d' "$SITE" sed -i '/AssignUserID/d' "$SITE"
@@ -125,11 +94,6 @@ else
FIRST_RUN=0 FIRST_RUN=0
fi fi
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
perl /opt/koha-init/ensure.pl \
|| log "WARNING: ensure.pl reported a problem (see above)"
# --- 5. background services (their `daemon` processes die with the container, # --- 5. background services (their `daemon` processes die with the container,
# so they are started explicitly on every start) ------------------------ # so they are started explicitly on every start) ------------------------
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/perl
# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
#
# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
# that know the secret get an answer.
use Modern::Perl;
use CGI qw( -utf8 );
use JSON qw( encode_json );
use Digest::SHA qw( sha256_hex );
use C4::Context;
use C4::Circulation qw( AddReturn );
use Koha::Items;
my $q = CGI->new;
sub reply {
my ( $q, $status, $data ) = @_;
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
encode_json($data);
exit;
}
my $secret = '';
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
reply( $q, '403 Forbidden', { status => 'forbidden' } )
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
my $barcode = $q->param('barcode') // '';
reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
my $item = Koha::Items->find( { barcode => $barcode } );
reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
# AddReturn needs to know which branch it is happening at.
my $branch = $item->homebranch;
my ( $doreturn, $messages, $borrower, $was_out );
eval {
C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
$was_out = $item->checkout ? 1 : 0;
( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
1;
} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
reply(
$q, '200 OK',
{
status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
messages => [ sort keys %{ $messages || {} } ],
}
);
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/perl
# Installed as .../intranet/cgi-bin/svc/quickadd-session (URL /cgi-bin/koha/svc/quickadd-session).
#
# Lets the Add books page (Quick Add) ask: "is this browser signed in to the staff site as
# someone allowed to edit the catalogue?" so staff do not have to log in a second time.
#
# It wraps Koha's own C4::Auth::check_cookie_auth (documented for exactly this: services that
# are invoked by pages authenticated in the usual way) and only answers callers that present a
# shared secret, so it cannot be used to probe sessions from outside. The browser's address is
# passed in explicitly, so Koha's "session is tied to the IP that logged in" rule keeps working.
use Modern::Perl;
use CGI qw( -utf8 );
use JSON qw( encode_json );
use Digest::SHA qw( sha256_hex );
use C4::Auth qw( check_cookie_auth );
my $q = CGI->new;
# Takes the CGI object as an argument: under Plack this file runs wrapped in a sub, where a named
# sub would otherwise see a stale copy of a file-level variable.
sub reply {
my ( $q, $status, $data ) = @_;
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
encode_json($data);
exit;
}
# 1. The caller must know the shared secret (a file only the Koha user and root can read).
my $secret = '';
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
reply( $q, '403 Forbidden', { status => 'forbidden' } )
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
# 2. Plausible-looking inputs only.
my $sid = $q->param('sid') // '';
my $ip = $q->param('ip') // '';
reply( $q, '200 OK', { status => 'failed' } )
unless $sid =~ /\A[A-Za-z0-9]{16,64}\z/ && $ip =~ /\A[0-9A-Fa-f:.]{3,45}\z/;
# 3. Ask Koha. Staff who may edit the catalogue (superlibrarians always may) are let in.
my ( $status, $session ) = check_cookie_auth( $sid, { editcatalogue => 'edit_catalogue' }, { remote_addr => $ip } );
reply( $q, '200 OK', { status => 'ok', user => scalar $session->param('id') } ) if $status eq 'ok' && $session;
reply( $q, '200 OK', { status => $status || 'failed' } );
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/perl
# Installed as .../intranet/cgi-bin/quickadd-value.pl (URL /cgi-bin/koha/quickadd-value.pl).
#
# Koha's REST API cannot add shelves/rooms (authorised value category LOC) or collections (CCODE),
# so the Library site asks this script to, with Koha's own Koha::AuthorisedValue class. Same shared-secret
# guard as quickadd-session / quickadd-return; only the two categories below can be touched.
use Modern::Perl;
use CGI qw( -utf8 );
use JSON qw( encode_json );
use Digest::SHA qw( sha256_hex );
use Koha::AuthorisedValues;
use Koha::AuthorisedValue;
my $q = CGI->new;
sub reply {
my ( $q, $status, $data ) = @_;
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
encode_json($data);
exit;
}
my $secret = '';
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
reply( $q, '403 Forbidden', { status => 'forbidden' } )
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
my $cat = $q->param('category') // '';
my $value = $q->param('value') // '';
my $desc = $q->param('description') // '';
utf8::decode($desc);
reply( $q, '200 OK', { status => 'invalid' } )
unless $cat =~ /\A(?:LOC|CCODE)\z/ && $value =~ /\A[A-Z0-9_-]{1,20}\z/ && length($desc) && length($desc) <= 80;
my $out = eval {
my $exists = Koha::AuthorisedValues->search( { category => $cat, authorised_value => $value } )->count;
return { status => 'exists' } if $exists;
Koha::AuthorisedValue->new( { category => $cat, authorised_value => $value, lib => $desc, lib_opac => $desc } )->store;
{ status => 'added' };
};
reply( $q, '200 OK', $out // { status => 'failed', error => "$@" } );
+2 -2
View File
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1 ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \ && apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if # Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
@@ -13,7 +13,7 @@ RUN apt-get update \
COPY certs/ /usr/local/share/ca-certificates/extra/ COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates RUN update-ca-certificates
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
RUN useradd --system --uid 10001 --home-dir /app quickadd \ RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data && mkdir -p /app /data && chown quickadd /data
Binary file not shown.
+17 -1118
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -37
View File
@@ -1,30 +1,29 @@
#!/bin/bash #!/bin/bash
################################################################################ ################################################################################
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library # menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk. # catalogue (Koha) running in Docker, shown on the kiosk as a public
# search terminal.
# #
# What gets installed (all from this checkout's library/ directory, deployed to # What gets installed (all from this checkout's library/ directory, deployed to
# $LIBRARY_DIR - see library/README.md for the stack itself): # $LIBRARY_DIR - see library/README.md for the stack itself):
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending. # - Koha: the catalogue ("OPAC", public search, no login) and the staff
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab. # interface (cataloguing, Z39.50 lookup, label printing).
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
# - MariaDB. # - MariaDB.
# Docker itself is installed on demand (lib/docker.sh). # Docker itself is installed on demand (lib/docker.sh).
# #
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a # On the kiosk the catalogue is added as one of the Sites (tabs) with
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at # duration 0 (stay put - it's a search terminal, not a rotating display).
# all, because Koha's own search permission also allows editing label layouts); the page # Staff and Quick Add are meant for the librarian's own device; whether they
# list stores that guest login like any other Basic Auth site. The admin login never # are reachable from the LAN or only from this kiosk is the "Network access"
# goes into the kiosk's config. The public catalogue (OPAC) is off by default. # choice below. NOTE: Docker publishes ports straight through iptables and
# # bypasses ufw, so "this kiosk only" is enforced by binding the published
# Whether other devices on the LAN can reach the library site is the "Network access" # ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
# (BIND_ADDR in library/.env), not by firewall rules.
# #
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first. # Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
################################################################################ ################################################################################
LIBRARY_CATALOGUE_TAB_NAME="Find a book" LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
################################################################################ ################################################################################
# Detection / state # Detection / state
@@ -43,13 +42,7 @@ library_is_running() {
[[ "$(docker_container_state library-koha)" == "running" ]] [[ "$(docker_container_state library-koha)" == "running" ]]
} }
# The page the kiosk shows: Quick Add's Find a book view on the library site.
library_catalogue_url() { library_catalogue_url() {
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
}
# Pages from earlier versions of this add-on pointed at the public catalogue.
library_old_catalogue_url() {
echo "http://localhost:$(library_env_get OPAC_PORT)/" echo "http://localhost:$(library_env_get OPAC_PORT)/"
} }
@@ -73,19 +66,19 @@ addon_library_status() {
health="$(docker_container_health library-koha)" health="$(docker_container_health library-koha)"
ip="$(get_ip_address)" ip="$(get_ip_address)"
echo "Library Catalog: ${state}${health:+ ($health)}" echo "Library Catalog: ${state}${health:+ ($health)}"
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)" echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
echo " Network: this kiosk only" echo " Network: this kiosk only"
else else
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/" echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
fi fi
} }
addon_library_menu_builder() { addon_library_menu_builder() {
if library_is_installed; then if library_is_installed; then
load_existing_config load_existing_config
local tab_label="Show Find a book on the kiosk" local tab_label="Show the catalogue on the kiosk"
library_tab_exists && tab_label="Stop showing Find a book on the kiosk" library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall") MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall) MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
else else
@@ -118,13 +111,13 @@ library_sync_files() {
library_add_tab() { library_add_tab() {
load_existing_config load_existing_config
if library_tab_exists; then if library_tab_exists; then
log_info "Find a book is already one of the kiosk's pages." log_info "The catalogue is already one of the kiosk's pages."
return 0 return 0
fi fi
URLS+=("$(library_catalogue_url)") URLS+=("$(library_catalogue_url)")
DURS+=("0") DURS+=("0")
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login USERS+=("")
PASSES+=("$(library_env_get VIEWER_PASSWORD)") PASSES+=("")
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME") NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
save_config save_config
} }
@@ -132,12 +125,11 @@ library_add_tab() {
# Drop every page that points at the catalogue's local address. # Drop every page that points at the catalogue's local address.
library_remove_tab() { library_remove_tab() {
load_existing_config load_existing_config
local url old idx local url idx
url="$(library_catalogue_url)" url="$(library_catalogue_url)"
old="$(library_old_catalogue_url)"
local -a nu=() nd=() nus=() np=() nn=() local -a nu=() nd=() nus=() np=() nn=()
for idx in "${!URLS[@]}"; do for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue [[ "${URLS[$idx]}" == "$url" ]] && continue
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}") nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}") np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
done done
@@ -207,7 +199,7 @@ action_library_install() {
return 1 return 1
fi fi
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
library_add_tab library_add_tab
fi fi
echo echo
@@ -225,12 +217,10 @@ action_library_show_access() {
ip="localhost" ip="localhost"
echo "(Network access is limited to this kiosk.)" echo "(Network access is limited to this kiosk.)"
fi fi
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/" echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/"
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/" echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/"
echo " Admin login: ${user} / ${pass}" echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/"
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)" echo " Login (staff and add books): ${user} / ${pass}"
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
echo " it cannot add or change anything (and it has no login on the staff site itself)."
echo echo
echo " Passwords live in $LIBRARY_DIR/.env (root only)." echo " Passwords live in $LIBRARY_DIR/.env (root only)."
pause pause
+2
View File
@@ -112,6 +112,8 @@ action_complete_uninstall() {
sudo rm -rf "$KIOSK_HOME" sudo rm -rf "$KIOSK_HOME"
echo "[6/12] Removing remaining systemd units..." echo "[6/12] Removing remaining systemd units..."
sudo systemctl disable --now kiosk-wifi-helper.socket 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.socket
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
sudo systemctl daemon-reload 2>/dev/null || true sudo systemctl daemon-reload 2>/dev/null || true
@@ -2,8 +2,10 @@ Section "ServerFlags"
# Disable Ctrl+Alt+Backspace (X server kill) # Disable Ctrl+Alt+Backspace (X server kill)
Option "DontZap" "true" Option "DontZap" "true"
# Disable VT switching (Ctrl+Alt+F1-F12) # Allow VT switching (Ctrl+Alt+F1-F8): virtual consoles are ENABLED by
Option "DontVTSwitch" "true" # default, as the installer says. Advanced -> Virtual Consoles (or the
# install prompt) turns this to "true" to disable them.
Option "DontVTSwitch" "false"
# Don't allow clients to disconnect on exit # Don't allow clients to disconnect on exit
Option "AllowClosedownGrabs" "false" Option "AllowClosedownGrabs" "false"
@@ -0,0 +1,14 @@
[Unit]
Description=Kiosk WiFi helper socket (used by the kiosk app's WiFi screen)
[Socket]
ListenStream=/run/kiosk-wifi-helper.sock
SocketUser=root
# Replaced with the kiosk user's group when installed (lib/provision.sh).
SocketGroup=@KIOSK_GROUP@
SocketMode=0660
Accept=yes
MaxConnections=4
[Install]
WantedBy=sockets.target
@@ -0,0 +1,10 @@
[Unit]
Description=Kiosk WiFi helper (one request)
[Service]
ExecStart=/usr/local/bin/kiosk-wifi-helper-socket
StandardInput=socket
StandardOutput=socket
StandardError=journal
# A connect waits up to ~30s for an address, plus netplan; never run forever.
RuntimeMaxSec=180
+104 -8
View File
@@ -4,10 +4,12 @@
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk # (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off. # itself when the console (Ctrl+Alt+F1..F6) is switched off.
# #
# The kiosk user may run this one script via passwordless sudo # The kiosk app reaches it through a root-owned socket
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a # (kiosk-wifi-helper.socket, group kiosk; see kiosk-wifi-helper-socket) -
# or, as a fallback, passwordless sudo (/etc/sudoers.d/kiosk-wifi) - it does a
# fixed set of things and nothing else: # fixed set of things and nothing else:
# #
# kiosk-wifi-helper ifaces {"adapters":[{"name","kind","connected","ssid","ip"}],"selected"}
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"} # kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]} # kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"} # kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
@@ -17,8 +19,10 @@
# WiFi country (regulatory domain), system # WiFi country (regulatory domain), system
# locale and keyboard layout; every field optional # locale and keyboard layout; every field optional
# #
# Every reply is one JSON line on stdout, exit 0 on success. connect # Every reply is one JSON line on stdout, exit 0 on success. status, scan
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh, # and connect accept an optional {"iface": "<adapter>"} in the stdin JSON
# to act on that adapter; without it the connected adapter is used (see
# pick_iface). connect writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface # 60-kiosk-wifi.yaml) and puts the previous one back if the interface
# gets no address - a typo in the password doesn't lose a WiFi network # gets no address - a typo in the password doesn't lose a WiFi network
# that was working. The password comes in on stdin, never argv (argv is # that was working. The password comes in on stdin, never argv (argv is
@@ -168,8 +172,76 @@ default_country() {
echo "${c^^}" echo "${c^^}"
} }
# Every wireless interface, by what it is (see detect_iface).
list_wifi_ifaces() {
local d
for d in $SYS_CLASS/net/*; do
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
basename "$d"
fi
done
}
# The wireless adapter that is connected right now (associated and has an
# address), if any. With a built-in card plus a USB dongle, the connected
# one is often not the first in name order - status/scan/connect must act
# on the adapter that is actually carrying the connection.
connected_iface() {
local i
for i in $(list_wifi_ifaces); do
if [[ -n "$(current_ssid "$i")" && -n "$(iface_ip "$i")" ]]; then
echo "$i"
return 0
fi
done
return 1
}
# Which adapter to use: the connected one, else the one our netplan file
# configures, else the first found.
pick_iface() {
connected_iface || prefer_configured_iface || detect_iface
}
# The request body (JSON on stdin), read once in main. REQ_IFACE is the
# adapter the caller asked for, or empty.
REQ_INPUT=""
REQ_IFACE=""
# Only a real wireless interface name is accepted - never pass caller text
# to a command or a YAML file unchecked.
valid_iface() {
local i
[[ "$1" =~ ^[A-Za-z0-9._-]{1,15}$ ]] || return 1
for i in $(list_wifi_ifaces); do
[[ "$i" == "$1" ]] && return 0
done
return 1
}
# "usb" or "built-in", from where the device sits in sysfs.
iface_kind() {
if readlink -f "$SYS_CLASS/net/$1/device" 2>/dev/null | grep -q '/usb'; then
echo usb
else
echo built-in
fi
}
pick_requested_or_default_iface() {
if [[ -n "$REQ_IFACE" ]]; then
valid_iface "$REQ_IFACE" || return 1
echo "$REQ_IFACE"
else
pick_iface
fi
}
need_iface() { need_iface() {
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in." if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
IFACE=$(pick_requested_or_default_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu" command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
} }
@@ -216,7 +288,7 @@ set_country() {
do_region_set() { do_region_set() {
local input country locale keyboard local input country locale keyboard
input=$(head -c 4096) input="$REQ_INPUT"
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -251,10 +323,27 @@ do_region_set() {
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}' '{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
} }
do_ifaces() {
local i rows="" sel
sel=$(pick_iface) || sel=""
for i in $(list_wifi_ifaces); do
local ssid ip
ssid=$(current_ssid "$i")
ip=$(iface_ip "$i")
rows+=$(jq -cn --arg n "$i" --arg k "$(iface_kind "$i")" --arg s "$ssid" --arg a "$ip" \
'{name:$n,kind:$k,connected:($s != "" and $a != ""),ssid:$s,ip:$a}')$'\n'
done
jq -cn --arg sel "$sel" --argjson a "$(printf '%s' "$rows" | jq -cs .)" \
'{ok:true,selected:$sel,adapters:$a}'
}
do_status() { do_status() {
local iface ssid="" ip="" configured local iface ssid="" ip="" configured
configured=$(configured_ssid) configured=$(configured_ssid)
if ! iface=$(detect_iface); then if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
if ! iface=$(pick_requested_or_default_iface); then
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}' jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
return 0 return 0
fi fi
@@ -351,7 +440,7 @@ PYEOF
do_connect() { do_connect() {
need_iface need_iface
local input ssid password local input ssid password
input=$(head -c 4096) input="$REQ_INPUT"
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given" ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request" password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -454,7 +543,14 @@ trap 'rm -f "$TMP_SCAN"' EXIT
exec 9> "$LOCK_FILE" exec 9> "$LOCK_FILE"
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment" flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
# One JSON request body on stdin (not when run by hand from a terminal).
if [[ ! -t 0 ]]; then
REQ_INPUT=$(head -c 4096)
REQ_IFACE=$(jq -r '.iface // "" | strings' <<< "$REQ_INPUT" 2>/dev/null) || REQ_IFACE=""
fi
case "${1:-}" in case "${1:-}" in
ifaces) do_ifaces ;;
status) do_status ;; status) do_status ;;
scan) do_scan ;; scan) do_scan ;;
connect) do_connect ;; connect) do_connect ;;
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
################################################################################
# kiosk-wifi-helper-socket - socket front end for kiosk-wifi-helper.
#
# Run by systemd (kiosk-wifi-helper@.service, one instance per connection to
# /run/kiosk-wifi-helper.sock) as root, with the connection as stdin AND
# stdout. The kiosk app connects, sends
#
# <action>\n<JSON request body>
#
# and half-closes; the reply is the helper's one JSON line.
#
# Why a socket and not `sudo`: the kiosk app can end up running with the
# Linux "no new privileges" flag, and sudo then refuses to run as root
# ("The 'no new privileges' flag is set"). A root service reached through a
# socket isn't affected by that flag. Who may connect is decided by the
# socket's owner/group/mode (root:kiosk 0660) - the same people who could
# run the helper through the old sudoers rule, no more.
#
# Only the fixed actions below are passed on; anything else is refused.
################################################################################
set -uo pipefail
HELPER="${KIOSK_WIFI_HELPER:-/usr/local/bin/kiosk-wifi-helper}"
# First line only; bash's read doesn't consume past the newline on a
# socket, so the JSON body stays on stdin for the helper.
IFS= read -r -t 10 -n 32 action || action=""
case "$action" in
ifaces|status|scan|connect|region-status|region-set)
exec "$HELPER" "$action"
;;
*)
echo '{"ok":false,"error":"unknown action"}'
exit 1
;;
esac
+14 -28
View File
@@ -74,8 +74,6 @@ STAFF_PORT=8081
QUICKADD_PORT=8082 QUICKADD_PORT=8082
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=s3cretPASS ADMIN_PASSWORD=s3cretPASS
VIEWER_USER=guest
VIEWER_PASSWORD=guestPASS
BIND_ADDR=${BIND_ADDR:-0.0.0.0} BIND_ADDR=${BIND_ADDR:-0.0.0.0}
ENV ENV
echo running > "$STUB_STATE" echo running > "$STUB_STATE"
@@ -100,7 +98,7 @@ source "$REPO/menus/addon_library.sh"
export KIOSK_UNATTENDED=0 export KIOSK_UNATTENDED=0
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; } tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; }
# ============================================================================ # ============================================================================
echo "== lib/docker.sh" echo "== lib/docker.sh"
@@ -135,10 +133,8 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG" check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG" check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
check "is_installed is now true" library_is_installed check "is_installed is now true" library_is_installed
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs) check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs)
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs)
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
addon_library_status > "$T/status.out" addon_library_status > "$T/status.out"
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out" check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
@@ -146,29 +142,21 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta
# ============================================================================ # ============================================================================
echo "== menu while installed" echo "== menu while installed"
addon_library_menu_builder addon_library_menu_builder
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}" check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}"
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}" check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
echo "== tab toggling is idempotent" echo "== tab toggling is idempotent"
library_add_tab >/dev/null 2>&1 library_add_tab >/dev/null 2>&1
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1 check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0 check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs) check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs)
addon_library_menu_builder addon_library_menu_builder
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}" check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}"
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1 check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1
# ============================================================================ # ============================================================================
echo "== an earlier version's tab (public catalogue address) is cleaned up"
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
library_remove_tab >/dev/null 2>&1
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
library_add_tab >/dev/null 2>&1
echo "== network access" echo "== network access"
: > "$STUB_LOG" : > "$STUB_LOG"
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
@@ -180,10 +168,8 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR
# ============================================================================ # ============================================================================
echo "== access screen" echo "== access screen"
printf '\n' | action_library_show_access > "$T/access.out" 2>&1 printf '\n' | action_library_show_access > "$T/access.out" 2>&1
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out" check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out"
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out" check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out"
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
# ============================================================================ # ============================================================================
echo "== update" echo "== update"
@@ -199,7 +185,7 @@ printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG" check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'" check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env" check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0 check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0
echo "== purge needs the typed confirmation" echo "== purge needs the typed confirmation"
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1 printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
@@ -209,8 +195,8 @@ library_add_tab >/dev/null 2>&1
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1 printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG" check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
check "install dir removed" test ! -e "$LIBRARY_DIR" check "install dir removed" test ! -e "$LIBRARY_DIR"
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0 check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs) check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs)
echo echo
echo "passed: $PASS failed: $FAIL" echo "passed: $PASS failed: $FAIL"