Author SHA1 Message Date
Claude 3ef968fd10 library: HTTPS via Caddy (install.sh --domain [--lan-tls]) so the live camera works; real visitor address behind a proxy; camera permission header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 03:45:26 +00:00
Claude 9fb512cdab library: same default ports as post-install (8097/8098/8099); borrower home shows loans with due info
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 03:39:27 +00:00
Claude f0a9728016 library: bare address opens Room for Books; Koha's own screens via the menu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 03:33:53 +00:00
Claude 6fdc5f4dd1 library: simple home page (look up, check out, return, add, add person, recently added), hamburger menu, footer; add books by title with matches to choose
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 03:15:01 +00:00
Claude 0af833a39d library: name is Room for Books (no article)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 01:24:58 +00:00
Claude 442b45c2e0 library: renamed A Room for Books; multi-tags; borrower sign-in (card number + password) with My books, renew and change password; AR pop-up/frame
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 01:04:45 +00:00
Claude 6f877d1107 library: live camera scanning over HTTPS, paste-to-fill AR reading levels (with interest level)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:49:33 +00:00
Claude 3d02d28a8c library: reading levels (manual + CSV import), library CSV export, AR BookFind link with copy-title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:35:19 +00:00
Claude 7f230c8e73 library: collections, QR library cards (phone scan), card sheets, book info from Open Library, add shelves/collections from the UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:27:27 +00:00
Claude b784d98c9d library: Lend / return, People and printable library cards in the Library site
Checkout via Koha REST, check-in through a secret-guarded script that runs Koha's AddReturn,
renewals, overdue list, add-a-person with auto card number, Code 39 library card page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:05:04 +00:00
Claude d01919e2ac library: Library home page (cover grid, filters, search) and book page with shelf/call-number editing
Quick Add becomes the everyday interface: Library grid with shelf/format/status filters,
a book page (copies, status, edit shelf and call number, add/remove copy, delete), read-only for the guest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 23:56:08 +00:00
Claude 10e991e158 library: staff signed in to Koha open Add books without a second login
Quick Add asks Koha (C4::Auth::check_cookie_auth via a secret-guarded svc script) whether the
browser's session may edit the catalogue; falls back to its own login otherwise. Cookie-authenticated
writes need an X-Quickadd header; the direct port never accepts cookies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 22:45:01 +00:00
Claude 770a6a1dfb library: login for everything - public catalogue off, read-only guest for Find a book
- the public catalogue is bound to the machine itself by default (OPAC_BIND) and,
  if ever exposed, requires a library-card login (OpacPublic=0)
- a read-only guest login (VIEWER_USER/VIEWER_PASSWORD) in Quick Add: it may load the
  page, read state and search; every other endpoint is admin-only (403); the page
  shows only Find a book
- the guest deliberately has NO Koha account: Koha's 'catalogue' permission, the
  only one that allows searching, also allows creating/editing label layouts and
  templates (tested: a guest created a layout), so it cannot be read-only
- koha/ensure.pl (every start): staff-home 'Library tools' block, and removal of a
  guest account an earlier version created
- kiosk add-on shows Find a book signed in as the guest; the admin login never
  goes into the kiosk config (tested, incl. a mutation check); old public-catalogue
  tabs are cleaned up
- existing installs keep their current public-catalogue behaviour on update

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 22:09:06 +00:00
Claude 522a33b127 library: one address - Add books inside the staff site, plus Find a book
- Apache in the Koha container serves Quick Add at /add/ on the staff site
  (KOHA_QUICKADD_URL); koha now starts after quickadd so the name resolves
- Quick Add: relative API paths so it works at / and /add/; Add books / Find a
  book tabs; search by title, author or ISBN showing each copy's shelf and status
  (/api/search); Manage and Public catalogue links
- staff home gets a 'Library tools' block (HTML customizations, how Koha 26.05
  stores it) linking to Add books
- installer, kiosk add-on and READMEs advertise one main address; the public
  catalogue stays on its own port (Koha gives public and staff sites the same URL
  shapes, so they cannot share one address)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 21:38:09 +00:00
Claude 5fc7450af7 library: scan with a phone camera (Quick Add)
A 'Scan with camera' button opens the phone's own camera app; the photo is shrunk
on the phone and read on the server with zbarimg, then goes through the normal add
flow (also available on the sticker step). Works over plain HTTP on iPhone and
Android - no HTTPS needed.

- /api/scan: JPEG/PNG only (checked by magic bytes before ImageMagick sees it),
  12 MB cap, login required, 20 s timeout, temp files cleaned up
- UPC-A reads (zbar reports 13 digits with a leading zero) normalised to the 12
  digits printed on the item so duplicate checks match
- tests/gen_photos.py + tests/check_scan.py: phone-like photos (tilt, blur, noise,
  low light, upside down, EXIF rotation, small, UPC, Code 39 sticker); 10/10 read
  at the size the page sends

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 13:12:58 +00:00
Claude 023a64b62c library: Dockerfile fails loudly and verifies the image
A '|| true' at the end of the install chain swallowed any earlier failure, so a
transient/blocked apt step produced a successful build with no Koha in it (found
when an update rebuilt without the proxy CA). Steps are now separate, memcached and
cron are installed explicitly, and a final step refuses to produce an image that is
missing koha-create, memcached, cron, apache2ctl, zebrasrv, ... Quick Add's image
verifies yaz and python3 the same way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 11:18:10 +00:00
Claude 49c35f765b library: proxy URL overrides, restore Quick Add header links
- OPAC_URL / STAFF_URL let Quick Add build correct links behind a reverse proxy
- restore the Catalogue/Staff header links (lost when the init block was
  rewritten) and verify them in the browser test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 11:09:11 +00:00
Claude 76866e8bbc library: barcode modes, patrons/lending, install-time shelves and item types
Quick Add:
- barcode choices: automatic (zero-padded, >= 7 digits), scan a pre-printed
  sticker per item, or none for now; bulk 'give barcodes to items without one';
  reserve a block of numbers and print the sheet ahead of time (link opens
  Koha's range printer prefilled)
- default shelf saved on the server (shared by every device)
- item types follow Koha; CSP allows cover redirects; aria attrs fixed

Koha bootstrap:
- default lending rule (3 weeks, 25 items, 3 renewals, no fines) - a fresh
  Koha refuses every checkout without one
- automatic library-card numbers (start at 1000001), fines off
- Koha's own auto-barcode off (one numbering scheme)
- patron card template (Avery 5371) and layout with a scannable barcode
- LIBRARY_CODE, SHELVES and ITEM_TYPES honoured at first install

Why 7 digits: decoded printed sheets - 7-digit Code 39 read back 40/40, 6-digit
all-numeric symbols were reinterpreted as Code 32 for ~1 in 10 values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 11:07:02 +00:00
Claude aa7950aa9e library: shelf picker in Quick Add, docs, status/ss fixes; kiosk README entry
- Quick Add: choose the shelf/room (Koha LOC values) once; every scanned item
  gets it and the choice is remembered; invalid values are ignored
- install.sh --status shows localhost in loopback mode; port probing works
  without ss
- library/README.md; kiosk Readme.md add-on section and licenses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:33:58 +00:00
Claude e08133dfc0 library: BIND_ADDR for loopback-only mode, real readiness check
- BIND_ADDR (default 0.0.0.0) on every published port; 127.0.0.1 keeps the
  stack reachable from the host only (Docker bypasses ufw, so this is the
  actual control the kiosk addon's 'this kiosk only' choice uses)
- Koha serves its web-installer page with HTTP 200 before the database
  exists, so 'port answers' was not 'ready': the entrypoint now writes
  /run/koha-ready, the healthcheck requires it, and install.sh waits for
  healthy + the quick-add health endpoint

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:30:41 +00:00
Claude 24c95c91f2 Add Library Catalog addon, lib/docker.sh and tests
- lib/docker.sh: shared Docker/Compose tooling (on-demand install via apt or
  get.docker.com, compose wrapper, container state/health, HTTP wait)
- menus/addon_library.sh: install/update/uninstall the Koha + Quick Add stack
  from library/ to /opt/kiosk-library, add the catalogue as a kiosk page
  (duration 0), choose kiosk-only vs LAN access, show addresses and login
- wire into install.sh menu, complete_uninstall and clone_settings
- tests/addon_library.test.sh: 41 checks against a stub sudo/docker and a
  fixture stack (no root, Docker or network needed)

Note: the 'network access' choice relies on a BIND_ADDR setting in the
library stack, added in a follow-up commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:29:22 +00:00
Claude 2ef8f68b9b library: add Quick Add page (scan an ISBN, the book is added)
A small standard-library Python service next to Koha:
- looks the code up via Z39.50 (yaz) then Open Library over HTTPS
- checks Koha for an existing copy (ISBN-10/13 and UPC/EAN aware)
- adds the record + an item with the next barcode via Koha's REST API
- scanner-friendly UI: auto-focus, audible feedback, duplicate card with
  'add another copy', manual entry when nothing is found, undo, and the
  barcode range to print labels for
Also: enable RESTBasicAuth, map 024$a to the ean column, wire the service,
port and credentials into compose/install.sh.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:24:54 +00:00
Claude f4da2608cd library/install.sh: retry builds on Docker Hub rate limits (HTTP 429)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 07:07:56 +00:00
Claude f0a0c4ad40 library: seed label templates/layouts, default item type, fix Apache setup
- Seed Avery 5160/5167 and Demco spine templates plus Spine, Spine+barcode
  and Barcode-only layouts (Dewey call number split across lines)
- Default 942$c (item type) to BOOK so a Z39.50 import saves without edits
- Enable Plack includes in addition to the plain ones (static files/rewrites)
- Swap Apache from mpm_itk to prefork after koha-create (itk can't switch
  users in a container and returned HTTP 500 on every page)
- Drop unneeded SETUID/SETGID caps

Verified live: staff login, Z39.50 search/import against a local server,
save + auto barcode, label PDF with Dewey spine + barcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:51:50 +00:00
Claude b4c844b124 library: pass PERL5LIB to bootstrap (koha-shell used to set it)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:30:12 +00:00
Claude a15478b382 library: run bootstrap via runuser, keep mpm_itk, add SETUID/SETGID caps
- koha-shell -c wraps the command in single quotes, which broke the env
  assignments; use runuser + env instead and verify tables were created
- koha-create requires mpm_itk, so keep it and grant the caps it needs
- stop any Apache started by koha-create before running it in foreground

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:29:27 +00:00
Claude 7244cfd918 library: fix entrypoint (Apache user, Listen 8081, start daemons each boot) and crash detection
- Run Apache as the instance user instead of mpm_itk AssignUserID
- Add Listen for the staff port; start zebra/indexer/worker/plack explicitly
- Tolerate koha-plack --enable returning non-zero under set -e
- install.sh: only fail on a crash loop, not a single restart

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:20:45 +00:00
Claude 5b79608695 Add library/: one-command Dockerized Koha home library (WIP)
Compose stack (MariaDB + Koha built from the Koha project's Debian
packages), headless bootstrap replacing the web installer, seeded item
types and LoC Z39.50 server, and an install.sh that installs Docker,
generates passwords and waits for the site. End-to-end testing in progress.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:13:25 +00:00
Outis e828805b9c Merge pull request #128 from outis1one/claude/optimistic-clarke-27gpk1
Claude/optimistic clarke 27gpk1
2026-11-05 16:43:42 -05:00
Claude 7cb4a661f9 Add SSH Access: enable sshd and import keys from GitHub/Launchpad (ssh-import-id)
Offered during first-time install and under Addons -> Remote Access.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-05 21:41:18 +00:00
Claude eb7bc14a9c Revert Tailscale/Netbird SSH toggles (misread request)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-05 21:40:49 +00:00
Claude ead4500406 Add Tailscale SSH and Netbird SSH: first-time install prompt and Remote Access toggles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-05 21:39:11 +00:00
Outis 4085866134 Merge pull request #127 from outis1one/claude/laughing-sagan-2oo2mx
Claude/laughing sagan 2oo2mx
2026-11-02 13:27:52 -05:00
Claude 763162fc57 First boot: detect a stalled online install by silence (10 min), not a 40 min cap
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-23 15:37:34 +00:00
Claude d30f7c7260 First boot: don't hang forever on a stalled online install; fall back to the offline bundle
Unattended installs on an --offline ISO cap the online attempt (default 40 min)
and finish from the bundle on timeout or failure. Interactive retry menu gains
[O] to install from the bundle. NodeSource curl gets timeouts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-23 15:33:54 +00:00
Outis d673d04259 Merge pull request #126 from outis1one/claude/sharp-carson-8tbjf5
Netbird menu: enable built-in SSH server on join; document SSH host a…
2026-10-17 13:07:32 -04:00
Claude 6cc5296cd6 Netbird menu: enable built-in SSH server on join; document SSH host aliases
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7JYaTBsSRDoJx2aDEjVp2
2026-10-17 16:57:20 +00:00
Outis 5a4e8a8734 Merge pull request #125 from outis1one/claude/sharp-carson-8tbjf5
Readme: document headless netbird up command
2026-10-17 12:53:33 -04:00
Claude 3e665688c5 Readme: document headless netbird up command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7JYaTBsSRDoJx2aDEjVp2
2026-10-17 16:50:12 +00:00
Outis 7f8947ade2 Merge pull request #124 from outis1one/claude/laughing-sagan-2oo2mx
Claude/laughing sagan 2oo2mx
2026-10-16 22:40:48 -04:00
Claude d0d39e48bb Region setting (WiFi country, locale, keyboard) on the Super+W screen and Web UI
kiosk-wifi-helper gains region-status/region-set. The country is persisted as
a cfg80211 regdom module option and in the WiFi netplan file; locale and
keyboard go through localectl, validated against what's installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 17:48:41 +00:00
Claude 9221fc85aa WiFi: set regulatory domain (country) in the WiFi wizard and web helper
Without a country Linux uses the restrictive world domain: networks show up
in scans but the adapter can't join them. The menu asks (default from the
existing config or system locale); the web helper uses the same default.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 17:42:44 +00:00
Claude b88d648e14 WiFi: dump link/regdomain/wpa log on failed connect, wait 45s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 17:32:36 +00:00
Outis 46903b93a4 Merge pull request #123 from outis1one/claude/laughing-sagan-2oo2mx
Claude/laughing sagan 2oo2mx
2026-10-16 12:00:17 -04:00
Claude c196a5f7e7 Turn off WiFi power save and USB autosuspend for Realtek dongles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 15:58:43 +00:00
Claude f91dbe4d5f WiFi: adapter chooser, optional wifis, wrong-password diagnosis
- Menu asks which adapter when several exist (shows USB/PCI); the web UI
  helper prefers the adapter already configured.
- Stale-entry cleanup also marks remaining installer wifis optional so a
  dead WiFi entry can't hold up boot.
- After a failed connect, read wpa_supplicant's log to say wrong password /
  network not found / rejected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 15:56:46 +00:00
Outis 5e7ffb2eb4 Merge pull request #122 from outis1one/claude/laughing-sagan-2oo2mx
WiFi: drop stale netplan wifis entries before apply
2026-10-16 11:37:12 -04:00
Claude 903a1e45e4 WiFi: drop stale netplan wifis entries before apply
netplan apply waits ~90s for every wifis: interface in every file and then
fails if one doesn't exist (e.g. installer config naming wlan0 while the
adapter is wlp1s0). Remove such entries (backup kept as *.kiosk-bak) before
applying, in both the menu and kiosk-wifi-helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 15:35:06 +00:00
45 changed files with 6465 additions and 23 deletions
+31
View File
@@ -427,11 +427,36 @@ Both can be used at the same time — they serve different purposes:
- **IPP Everywhere support**
- **PDF printing** via cups-pdf
### Library Catalog (Koha)
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
- **Add books by scanning** - a Quick Add page: scan or type an ISBN (or tap "Scan with camera" on a phone) and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
- **Uninstall keeps your books** unless you also choose to delete the data (typed `DELETE` confirmation); Complete Uninstall removes everything
- Needs roughly 2 GB RAM and 3 GB disk; the first build takes 5-10 minutes. See [`library/README.md`](library/README.md) for the stack itself, and run `bash tests/addon_library.test.sh` for the add-on's tests
### Remote Access
- **VNC** - x11vnc for remote desktop
- **WireGuard VPN** - Config paste support
- **Tailscale VPN** - Auth key support
- **Netbird VPN** - Setup key support
- Headless join, no browser login (get a setup key from the Netbird dashboard):
```bash
sudo netbird up --setup-key YOUR_SETUP_KEY --allow-server-ssh
```
`--allow-server-ssh` enables Netbird's built-in SSH server (peers also need an SSH policy in the dashboard).
#### SSH Host aliases
`~/.ssh/config` lets you `ssh <alias>` instead of typing `ssh user@1.2.3.4` every time — especially handy once machines are reachable over a VPN/Netbird overlay network where the IP is easy to forget:
```
Host myserver
HostName 100.x.x.x
User someuser
Port 22
```
### Advanced
- **Emergency WiFi Hotspot** - Auto-starts if no internet after boot (configurable during install)
@@ -1326,7 +1351,13 @@ This project bundles or installs several open-source components under their resp
- **Repository:** https://github.com/netbirdio/netbird
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
### Koha (Library Catalog add-on)
- **License:** GPL-3.0-or-later
- **Source:** https://koha-community.org
- **Packages:** installed from the Koha project's own Debian repository (https://debian.koha-community.org) inside a Docker image built on your machine
### MariaDB and Docker (Library Catalog add-on)
- **MariaDB** - GPL-2.0 (https://mariadb.org); **Docker Engine** - Apache-2.0 (https://www.docker.com)
---
+11 -3
View File
@@ -38,7 +38,11 @@
# Display/Lockout, the webui/ Node app installed as a systemd
# service; no login of its own, put it behind your own reverse proxy
# with Authelia forward-auth if it needs to be reachable beyond a
# trusted LAN - see the file header).
# trusted LAN - see the file header), Library Catalog (menus/
# addon_library.sh - a self-hosted Koha library catalogue in Docker with
# Z39.50 lookup, label printing and a scan-to-add page, shown on the
# kiosk as a public search terminal; installs Docker on demand via
# lib/docker.sh).
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
# manual update, fix blank screen), Factory Reset
@@ -67,6 +71,8 @@ source "$SCRIPT_DIR/lib/menu.sh"
source "$SCRIPT_DIR/lib/config.sh"
# shellcheck source=lib/electron.sh
source "$SCRIPT_DIR/lib/electron.sh"
# shellcheck source=lib/docker.sh
source "$SCRIPT_DIR/lib/docker.sh"
# shellcheck source=menus/sites.sh
source "$SCRIPT_DIR/menus/sites.sh"
# shellcheck source=menus/display.sh
@@ -97,6 +103,8 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
# shellcheck source=menus/addon_webui.sh
source "$SCRIPT_DIR/menus/addon_webui.sh"
# shellcheck source=menus/addon_library.sh
source "$SCRIPT_DIR/menus/addon_library.sh"
# shellcheck source=menus/advanced_electron.sh
source "$SCRIPT_DIR/menus/advanced_electron.sh"
# shellcheck source=menus/advanced_upgrade.sh
@@ -179,8 +187,8 @@ core_settings_menu() {
}
addons_menu_builder() {
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI")
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu)
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)")
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu)
}
addons_menu() {
+66 -2
View File
@@ -22,6 +22,10 @@
# KIOSK_OFFLINE_WAIT=N on an ISO built with --offline: seconds to wait
# for internet before installing from the bundled
# packages instead (default 90).
# KIOSK_STALL_TIMEOUT=N on an --offline ISO, unattended: if the install
# prints nothing for N seconds (a stalled download on
# a flaky link), stop it and finish from the bundled
# packages instead (default 600).
################################################################################
set -uo pipefail
@@ -40,6 +44,7 @@ OFFLINE_FLAG="$STATE_DIR/offline-mode"
KIOSK_UNATTENDED=0
KIOSK_OFFLINE_WAIT=90
KIOSK_STALL_TIMEOUT=600
# shellcheck source=/dev/null
[[ -f "$CONF" ]] && source "$CONF"
@@ -61,6 +66,21 @@ if [[ "${1:-}" == "--cleanup" ]]; then
exit 0
fi
have_bundle() { [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; }
# Finish this install from the bundled packages instead of the internet
# (cleanup() turns it back off). install.sh notices via lib/config.sh's
# offline_mode_active.
switch_to_offline() {
touch "$OFFLINE_FLAG"
if ! "$OFFLINE_APT" on; then
echo "ERROR: couldn't switch apt to the offline bundle."
rm -f "$OFFLINE_FLAG"
return 1
fi
return 0
}
# Make sure tty1 gets its login prompt back. getty@tty1 is ordered after
# this unit and normally starts by itself once we exit; this just covers
# a getty that was stopped or never queued (e.g. started by hand later).
@@ -83,13 +103,15 @@ banner() {
# drop to a shell as the admin user to fix it, or give up until next boot.
# Returns 0 to retry, 1 to give up.
offer_retry() {
local choice
local choice offline_opt=""
have_bundle && [[ ! -f "$OFFLINE_FLAG" ]] && offline_opt=" [O] Install from the offline bundle on this disk"
while true; do
echo
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt"
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt$offline_opt"
read -r -p "Choice [R]: " choice
case "${choice,,}" in
""|r) return 0 ;;
o) [[ -n "$offline_opt" ]] && switch_to_offline && return 0 ;;
s) runuser -l "$ADMIN_USER" ;;
q)
echo "Setup will run again on next boot, or run it now with:"
@@ -227,6 +249,39 @@ run_install() {
# runuser rather than sudo -u: sudo would set SUDO_USER=root, which
# config.sh reads as "the admin running this tool".
# A stalled download on a flaky connection would otherwise hang an
# unattended install forever. With the offline bundle as a safety net,
# watch the log: no new output for KIOSK_STALL_TIMEOUT seconds means
# stuck, so the whole process group is stopped (the caller then
# finishes from the bundle).
if [[ "$KIOSK_UNATTENDED" == "1" ]] && have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
touch "$LOG"
setsid bash -c '
runuser -u "$1" -- env -i "${@:4}" bash -c "cd \"\$1\" && exec ./install.sh" _ "$2" 2>&1 | tee -a "$3"
exit "${PIPESTATUS[0]}"
' _ "$ADMIN_USER" "$REPO_DEST" "$LOG" "${envs[@]}" &
local pid=$!
(
while kill -0 "$pid" 2>/dev/null; do
sleep 20
age=$(( $(date +%s) - $(stat -c %Y "$LOG") ))
if (( age > KIOSK_STALL_TIMEOUT )); then
echo
echo "No output for ${age}s - the online install looks stuck; stopping it."
kill -TERM -- "-$pid" 2>/dev/null
sleep 30
kill -KILL -- "-$pid" 2>/dev/null
break
fi
done
) &
local watcher=$!
wait "$pid"
local rc=$?
kill "$watcher" 2>/dev/null
wait "$watcher" 2>/dev/null
return "$rc"
fi
runuser -u "$ADMIN_USER" -- env -i "${envs[@]}" \
bash -c 'cd "$1" && exec ./install.sh' _ "$REPO_DEST" \
2>&1 | tee -a "$LOG"
@@ -249,6 +304,15 @@ while true; do
echo
echo "install.sh did not finish (exit code $rc). Full output: $LOG"
if [[ "$KIOSK_UNATTENDED" == "1" ]]; then
# Online attempt failed or stalled: the bundle on this disk is
# always going to work, so use it rather than retry the same thing.
if have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
echo "Online install didn't finish - continuing from the offline bundle on this disk."
if switch_to_offline; then
attempt=$((attempt + 1))
continue
fi
fi
if (( attempt >= 3 )); then
echo "Giving up after $attempt attempts; will try again next boot."
release_tty
+7
View File
@@ -1328,6 +1328,13 @@ ipcMain.handle('wifi-request',async(event,req)=>{
}
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='status'||action==='scan')return runWifiHelper(action);
if(action==='region-status')return runWifiHelper('region-status');
if(action==='region-set'){
const input={};
for(const k of['country','locale','keyboard'])if(req[k])input[k]=String(req[k]);
console.log('[WIFI] Region set:',JSON.stringify(input));
return runWifiHelper('region-set',input);
}
if(action==='connect'){
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
+68 -1
View File
@@ -26,6 +26,10 @@
color: #ecf0f1;
outline: none;
}
select {
width: 100%; padding: 14px; font-size: 18px; border: 2px solid #34495e;
border-radius: 10px; background: #34495e; color: #ecf0f1;
}
input:focus { border-color: #3498db; }
.row { display: flex; gap: 10px; align-items: center; }
.row input { flex: 1; }
@@ -114,6 +118,20 @@
</div>
</div>
<details id="region-panel" class="hidden" style="margin-top: 18px">
<summary style="font-size: 17px; cursor: pointer; color: #bdc3c7">🌍 Region (country, locale, keyboard)</summary>
<label for="country">WiFi country (2 letters, e.g. US) - needed to join many 5 GHz networks</label>
<input id="country" type="text" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
<label for="locale">Language / locale</label>
<select id="locale"></select>
<label for="keyboard">Keyboard layout</label>
<select id="keyboard"></select>
<div class="actions">
<button class="primary" id="region-save">Save region</button>
<button id="region-from-locale">Country from locale</button>
</div>
</details>
<div id="message"></div>
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
@@ -135,7 +153,7 @@
function setBusy(on) {
busy = on;
for (const id of ['scan', 'connect', 'pin-ok']) $(id).disabled = on;
for (const id of ['scan', 'connect', 'pin-ok', 'region-save']) $(id).disabled = on;
}
function signalBars(dbm) {
@@ -210,6 +228,52 @@
refreshStatus();
}
function fillSelect(sel, items, current) {
sel.textContent = '';
const list = items.slice();
if (current && !list.includes(current)) list.unshift(current);
for (const v of list) {
const o = document.createElement('option');
o.value = v; o.textContent = v;
if (v === current) o.selected = true;
sel.appendChild(o);
}
}
async function loadRegion() {
const r = await request('region-status');
if (!r.ok) return;
$('country').value = r.country || '';
fillSelect($('locale'), r.locales || [], r.locale);
fillSelect($('keyboard'), r.keyboards || [], r.keyboard);
$('region-panel').classList.remove('hidden');
}
async function saveRegion() {
if (busy) return;
setBusy(true);
message('Saving region…', 'busy');
const r = await request('region-set', {
country: $('country').value.trim(),
locale: $('locale').value,
keyboard: $('keyboard').value,
});
setBusy(false);
if (r.ok) {
message(r.restart ? '✓ Saved. Locale and keyboard apply after a restart; the country applies now.' : '✓ Saved', 'ok');
loadRegion();
} else {
message(r.error || 'Could not save', 'error');
}
}
// en_US.UTF-8 -> US
function countryFromLocale() {
const m = /_([A-Z]{2})/.exec($('locale').value);
if (m) $('country').value = m[1];
else message("That locale doesn't name a country", 'error');
}
async function unlock() {
if (busy) return;
setBusy(true);
@@ -224,12 +288,15 @@
$('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden');
refreshStatus();
loadRegion();
scan();
}
$('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan);
$('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect);
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
+5
View File
@@ -42,6 +42,11 @@
# other addon's own scripts.
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker
# stack is deployed here from this checkout's library/ directory. Root-owned
# on purpose - it holds the generated database/admin passwords in .env.
: "${LIBRARY_DIR:=/opt/kiosk-library}"
# Offline install bundle - only present on systems installed from an ISO
# built with `iso/build-iso.sh --offline`: a local apt repository (every
# package provisioning and the CUPS addon install) plus prebuilt
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
################################################################################
# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a
# containerised service (the Library Catalog addon is the first).
#
# Everything here goes through `sudo docker ...`: the kiosk admin runs
# install.sh as a regular user and is deliberately NOT put in the `docker`
# group (docker-group membership is root-equivalent).
#
# Depends on: lib/menu.sh (log_* helpers) being sourced first.
################################################################################
# Overridable so tests can substitute stubs for the real binaries.
: "${DOCKER_BIN:=docker}"
docker_is_installed() {
command -v "$DOCKER_BIN" >/dev/null 2>&1 \
&& sudo "$DOCKER_BIN" compose version >/dev/null 2>&1
}
docker_daemon_running() {
sudo "$DOCKER_BIN" info >/dev/null 2>&1
}
# Install Docker Engine + the Compose plugin if they are missing.
#
# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2):
# preferred because it also works through the offline apt bundle's mirror and
# gets security updates with everything else. On releases without
# docker-compose-v2 (22.04), fall back to Docker's official install script.
docker_ensure_installed() {
if docker_is_installed; then
docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true
log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed"
return 0
fi
log_info "Installing Docker..."
if ! sudo apt-get update -qq; then
log_error "apt update failed - check the network/package sources and try again"
return 1
fi
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \
|| { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; }
else
log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script"
have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; }
local script
script=$(mktemp)
if ! curl -fsSL https://get.docker.com -o "$script"; then
rm -f "$script"
log_error "Could not download Docker's install script"
return 1
fi
sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; }
rm -f "$script"
fi
sudo systemctl enable --now docker >/dev/null 2>&1 || true
if ! docker_is_installed || ! docker_daemon_running; then
log_error "Docker was installed but is not working - check: sudo systemctl status docker"
return 1
fi
log_success "Docker installed"
}
# docker_compose DIR args... -> sudo docker compose --project-directory DIR args...
docker_compose() {
local dir="$1"; shift
sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@"
}
# docker_container_state NAME -> running | exited | restarting | ... | missing
docker_container_state() {
sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing"
}
# docker_container_health NAME -> healthy | unhealthy | starting | none | missing
docker_container_health() {
sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \
|| echo "missing"
}
# docker_wait_http URL [TIMEOUT_SECONDS]
# Polls until URL answers 2xx/3xx; returns 1 on timeout.
docker_wait_http() {
local url="$1" timeout="${2:-300}" waited=0
while (( waited < timeout )); do
if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then
return 0
fi
sleep 5
waited=$((waited + 5))
done
return 1
}
# docker_free_port START -> prints the first TCP port >= START nothing listens on.
docker_free_port() {
local port="$1"
while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do
port=$((port + 1))
done
echo "$port"
}
+8 -1
View File
@@ -127,7 +127,7 @@ provision_install_nodejs() {
# The bundle carries NodeSource's own nodejs package.
sudo apt install -y nodejs
else
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
curl -fsSL --connect-timeout 20 --max-time 300 https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
fi
fi
@@ -242,6 +242,9 @@ 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"
fi
rm -f "$tmp"
# 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
sudo udevadm control --reload-rules 2>/dev/null || true
}
provision_configure_firewall() {
@@ -346,6 +349,10 @@ provision_configure_kiosk_settings() {
log_info "Configure later: Advanced -> Emergency Hotspot"
fi
if [[ "${KIOSK_UNATTENDED:-0}" != "1" ]]; then
ssh_first_time_setup
fi
echo
echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default."
if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then
+8
View File
@@ -0,0 +1,8 @@
# Generated by install.sh - contains passwords
.env
# Extra CA certificates dropped in for building behind a proxy
koha/certs/*.crt
quickadd/certs/*.crt
# Generated by install.sh --domain
Caddyfile
caddy-root.crt
+316
View File
@@ -0,0 +1,316 @@
# Room for Books: a home library (Koha + the Room for Books site, in Docker)
A self-hosted library catalogue you can set up in one command:
```bash
sudo ./install.sh
```
It installs Docker if it is missing, generates the passwords, builds the images,
starts everything and prints the addresses and your login. Running it again is safe;
it never overwrites your `.env` (passwords) or your data.
| What | Address | Login |
|---|---|---|
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8098` | `admin` and the generated password |
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8098/add/` | same login (asked once more) |
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8097` | none |
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
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
(`8099`), which is handy behind a proxy but not needed otherwise.
## What it is made of
* **Koha**: the open-source library system, built from the Koha project's own Debian
packages (no third-party Koha images). Installed and configured automatically; there is
no web installer to click through.
* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added".
* **MariaDB**: the database.
Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes.
## Adding books
Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode
scanner works as a keyboard, so just scan.
1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot
be reached (many networks block port 210), **Open Library** over HTTPS is used instead.
2. If you already have the title, you are asked whether to add **another copy**.
3. Otherwise the record is added with a barcode (see **Barcodes** below), the Dewey (or LC) call
number from the record, the item type chosen at the top (Book, Movie, Music, Game) and the shelf
you picked.
4. Nothing found? A short form lets you type the title and it is added anyway.
5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers
are never reused, because a label may already be printed.
DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you
own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title
for many of them.
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
stack of books without looking at the screen.
### Scanning with a phone camera
Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
can't read the photo you are told, and nothing is added.
* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
Bluetooth scanner paired to the phone also works, since it types into the page.
## Barcodes: your choice
Barcodes are optional. Open **Barcodes and settings** at the bottom of the Add books page:
| Choice | What happens |
|---|---|
| **Number them automatically** (default) | Each new item gets the next number, zero-padded to 7 digits (`0000042`). |
| **I stick a pre-printed barcode on each one** | After the ISBN is recognised you scan the sticker on the book. A sticker that is already in use is refused. |
| **No barcodes for now** | Items are added without one; the catalogue still shows them. |
Later, in the same panel:
* **Give them barcodes** numbers every item that has none, in one go, and shows the range to print.
* **Print a sheet of barcodes ahead of time** reserves a block (say 80 numbers), and the link opens
Koha's label printer with that range filled in. Print the sheet, and stick the labels on whenever you like.
Reserved numbers are never handed out again.
Good to know:
* Koha's range printer needs both ends to be the same length, so barcodes are fixed-width digits. They are
**at least 7 digits** on purpose: 6-digit all-number Code 39 barcodes can be misread as the pharmacy
"Code 32" format by some scanners (checked with a decoder: 7 digits read back perfectly, 6 did not).
* Prefixes (such as `HOME-0042`) are not offered: Koha's range printer cannot print them.
* Barcode types Koha can print: Code 39 (default, read by every scanner), Code 39 with a Modulo-43 or
Modulo-10 check digit (needs a scanner set up for check digits), EAN-13 (exactly 12-13 digits),
COOP 2 of 5 and Industrial 2 of 5. Change it under *Cataloging → Label creator → Label layouts*.
* Koha's own automatic barcode is switched off so there is one numbering scheme. Items added in the staff
interface have no barcode until you press **Give them barcodes**.
## Printing labels
From the Add books page, **Print labels** opens Koha's label printer with the barcode range of what you
just added filled in (you may be asked to log in to the staff interface). Pick a template and layout
and print at 100% scale (not "fit to page") so the labels line up.
| Template | Sheet |
|---|---|
| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode |
| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only |
| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines |
| Layout | Prints |
|---|---|
| Spine + barcode | call number (split over lines) with the barcode below |
| Spine - call number | call number only |
| Barcode only | barcode |
## People and lending
Lending is set up for a home library: 3-week loans, up to 25 items, 3 renewals, **no fines**.
Change it under *Administration → Circulation and fines rules*.
1. **Add a person**: staff interface → *Patrons → New patron → Friend / borrower*. Only the name is
required; the **library card number is assigned automatically** (`1000001`, `1000002`, ...). Numbers
start well away from item barcodes so a scan can never mean both.
2. **Print the card**: *Tools → Patron card creator → New batch*, add the patron's number, *Export card
batch*, template **Avery 5371**, layout **Library card**. The card carries the name and a scannable barcode.
3. **Check out**: *Circulation → Check out*. Scan the person's card, then scan each book. The due date appears
on screen.
4. **Check in**: *Circulation → Check in*, scan the book.
A USB or Bluetooth scanner types the number and presses Enter, so it works in all of these boxes.
## Finding the books
The public catalogue searches title, author, subject and ISBN, and shows each copy's
availability, call number and **shelving location**.
On the Add books page, pick the **Shelf / room** before you scan; every book you add
gets it. The choice is saved on the server, so every device shares it. If all your books are in one
place, leave it on *(not set / all in one place)*; nothing is shown and nothing needs changing. Change it
whenever you move to another shelf.
The list comes from the staff interface (*Administration → Authorized values → LOC*), which
starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
for example "Living room - bookcase 2". (Koha has no way to add a room from the Add books page.)
## Common tasks
```bash
sudo ./install.sh --status # addresses, login, container health
sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept)
sudo ./install.sh --stop # stop everything
sudo ./install.sh --start # start again
sudo docker logs -f library-koha
```
Settings live in `.env` (created on first run, mode 600). Useful ones:
| Setting | Meaning |
|---|---|
| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access |
| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports |
| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries |
| `LIBRARY_NAME` | name shown on the catalogue (first install only) |
After editing `.env` run `sudo docker compose up -d`.
## Behind a proxy or on a locked-down network
* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`.
* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause.
* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically.
## Backups
Your books live in two Docker volumes: `library_db-data` (the catalogue) and
`library_koha-data` (search index, uploads). Back up the database with:
```bash
sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql
```
The search index is rebuilt automatically if it is lost.
## Security notes
* The staff interface and Quick Add are protected by the generated password, over plain HTTP.
Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with
HTTPS in front.
* Quick Add uses the staff `admin` login to talk to Koha's API.
## Layout
```
install.sh one-command setup and --status/--update/--stop/--start
docker-compose.yml db + koha + quickadd
koha/ Dockerfile (Koha from the project's Debian repo), entrypoint,
bootstrap.pl (headless install), seed/ (item types, Z39.50
servers, label templates/layouts)
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`.
+102
View File
@@ -0,0 +1,102 @@
name: library
services:
db:
image: mariadb:10.11
container_name: library-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: koha_library
MARIADB_USER: koha_library
MARIADB_PASSWORD: ${DB_PASSWORD}
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"]
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 20
koha:
build:
context: ./koha
args:
KOHA_SUITE: ${KOHA_SUITE:-stable}
image: library-koha:local
container_name: library-koha
restart: unless-stopped
depends_on:
db:
condition: service_healthy
quickadd:
condition: service_started
environment:
TZ: ${TZ:-UTC}
KOHA_QUICKADD_URL: http://quickadd:8082
KOHA_SSO_SECRET: ${SSO_SECRET:-}
KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
KOHA_ADMIN_PASS: ${ADMIN_PASSWORD}
KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library}
KOHA_LIBRARY_CODE: ${LIBRARY_CODE:-HOME}
KOHA_SHELVES: ${SHELVES:-}
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports:
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8097}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8098}:8081"
volumes:
- koha-data:/var/lib/koha
# Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API).
quickadd:
build:
context: ./quickadd
image: library-quickadd:local
container_name: library-quickadd
restart: unless-stopped
environment:
TZ: ${TZ:-UTC}
KOHA_API: http://koha:8081/api/v1
SSO_SECRET: ${SSO_SECRET:-}
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VIEWER_USER: ${VIEWER_USER:-guest}
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8097}
STAFF_PORT: ${STAFF_PORT:-8098}
OPAC_URL: ${OPAC_URL:-}
STAFF_URL: ${STAFF_URL:-}
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
ports:
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8099}:8082"
volumes:
- 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:
db-data:
koha-data:
quickadd-data:
caddy-data:
caddy-config:
+246
View File
@@ -0,0 +1,246 @@
#!/bin/bash
# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian.
#
# sudo ./install.sh # install Docker if needed, build, start
# sudo ./install.sh --status # show URLs and container health
# sudo ./install.sh --update # rebuild with the latest Koha and restart
# 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:
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
# LIBRARY_CODE=HOME short code for the library (letters/digits)
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (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)
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; }
c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; }
RUN_USER="${SUDO_USER:-root}"
# --- Docker -------------------------------------------------------------------
ensure_docker() {
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed"
else
c_info "Installing Docker (official get.docker.com script)..."
command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; }
curl -fsSL https://get.docker.com | sh
docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin
c_ok "Docker installed"
fi
systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then
usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)"
fi
docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; }
}
port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
else # minimal systems without iproute2
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
fi
}
free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; }
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; }
lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
dc() { docker compose --project-directory "$HERE" "$@"; }
# Docker Hub rate-limits anonymous pulls (HTTP 429), which is common on shared
# IPs; retry with a growing pause instead of failing the whole install.
with_retry() {
local n=1 max=5 wait=15
until "$@"; do
[ "$n" -ge "$max" ] && { c_err "Failed after $max attempts: $*"; return 1; }
c_warn "Attempt $n failed (Docker Hub rate limit or network?) - retrying in ${wait}s..."
sleep "$wait"; n=$((n + 1)); wait=$((wait * 2))
done
}
show_status() {
[ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; }
# shellcheck disable=SC1091
. ./.env
local ip; ip="$(lan_ip)"; ip="${ip:-localhost}"
if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then
ip="localhost"
echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)"
fi
dc ps
echo
if [ -n "${DOMAIN:-}" ]; then
echo " Your library (HTTPS): https://$DOMAIN <- use this one: the live camera scanner needs it"
echo " (Koha's own screens: https://$DOMAIN/cgi-bin/koha/mainpage.pl, or the menu inside)"
fi
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
}
# --- 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
--status) show_status; exit 0 ;;
--stop) dc stop; exit 0 ;;
--start) dc start; exit 0 ;;
--update)
ensure_docker
with_retry dc build --pull --no-cache koha
dc up -d
show_status; exit 0 ;;
"" ) ;;
*) c_err "Unknown option: $1"; exit 1 ;;
esac
ensure_docker
# --- Settings (.env is created once and never overwritten) ------------------------
if [ ! -f .env ]; then
c_info "Generating settings and passwords..."
TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
umask 077
cat > .env << ENVEOF
# Generated by install.sh - keep this file private.
LIBRARY_NAME="${LIBRARY_NAME:-My Library}"
LIBRARY_CODE=${LIBRARY_CODE:-HOME}
SHELVES="${SHELVES:-}"
ITEM_TYPES="${ITEM_TYPES:-}"
TZ=${TZ_NOW}
OPAC_PORT=$(free_port "${OPAC_PORT:-8097}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8098}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8099}")
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_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_PASSWORD=$(rand 32)
KOHA_SUITE=stable
ENVEOF
umask 022
[ "$RUN_USER" = root ] || chown "$RUN_USER" .env
else
c_info "Re-using existing .env"
# Settings added in later versions: append them to an older .env.
if ! grep -q '^QUICKADD_PORT=' .env; then
echo "QUICKADD_PORT=$(free_port 8099)" >> .env
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
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
fi
fi
[ -n "$DOMAIN_ARG" ] && configure_caddy
if [ -n "${EXTRA_CA:-}" ]; then
cp "$EXTRA_CA" koha/certs/extra-ca.crt
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
c_info "Trusting extra CA during the build: $EXTRA_CA"
fi
# --- Build and start ---------------------------------------------------------------
c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..."
with_retry dc build
c_info "Starting..."
with_retry dc up -d
# --- Wait until the site answers ---------------------------------------------------
# shellcheck disable=SC1091
. ./.env
c_info "First start installs the database - waiting until Koha reports healthy..."
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)"
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
# 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
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
fi
sleep 5
done
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
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
show_status
+56
View File
@@ -0,0 +1,56 @@
# Koha (open-source library system) built from the Koha project's own Debian
# packages - no third-party Koha images involved.
#
# KOHA_SUITE stable (default) | oldstable | unstable
# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files;
# set "yes" if you want to install other languages later
FROM debian:bookworm-slim
ARG KOHA_SUITE=stable
ARG KEEP_TRANSLATIONS=no
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
# you are behind a TLS-inspecting proxy. Empty by default.
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
# Each step stands alone and fails the build on error: a swallowed failure here once produced a
# "successful" image with no Koha in it. memcached and cron are named explicitly rather than
# trusted to arrive as dependencies of koha-common.
RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \
> /etc/apt/sources.list.d/koha.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends koha-common mariadb-client memcached cron
RUN a2enmod rewrite headers proxy_http cgi deflate >/dev/null \
&& (a2dissite 000-default >/dev/null 2>&1 || true)
RUN if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \
&& rm -rf /var/lib/apt/lists/* /usr/share/doc/*
# Refuse to produce an image that cannot run.
RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-schema apache2ctl memcached cron mariadb zebrasrv; do \
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
done
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
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/
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
EXPOSE 80 8081
VOLUME ["/var/lib/koha"]
HEALTHCHECK --interval=10s --timeout=10s --start-period=300s --retries=6 \
CMD test -f /run/koha-ready && curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1
ENTRYPOINT ["/opt/koha-init/entrypoint.sh"]
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/perl
# Headless replacement for Koha's web installer + onboarding wizard.
# Run as the instance user: koha-shell -c "perl bootstrap.pl" <instance>
#
# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a
# future Koha upgrade keeps working), then seeds a home-library setup.
use Modern::Perl;
use C4::Context;
use C4::Installer;
use Koha;
use Koha::Libraries;
use Koha::Patrons;
use Koha::Patron::Categories;
use Koha::CirculationRules;
use Koha::ItemTypes;
use Koha::AuthorisedValues;
$| = 1;
my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed';
my $admin_pass = $ENV{KOHA_ADMIN_PASS};
die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass;
my %env = (
admin_user => $ENV{KOHA_ADMIN_USER} || 'admin',
admin_pass => $admin_pass,
library => $ENV{KOHA_LIBRARY_NAME} || 'My Library',
code => uc( ( $ENV{KOHA_LIBRARY_CODE} || 'HOME' ) =~ s/[^A-Za-z0-9]//gr ) || 'HOME',
);
my $installer = C4::Installer->new();
my $dbh = C4::Context->dbh;
my $intranet = C4::Context->config('intranetdir');
my $data = "$intranet/installer/data/mysql";
# Already installed? (the entrypoint checks too; this is a safety net)
my ($tables) = $dbh->selectrow_array(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()");
if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; }
say "bootstrap: loading database schema...";
if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; }
$installer->set_marcflavour_syspref('marc21');
# Same ordering rules the web installer applies (sysprefs first, userflags last).
my @files = (
sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ),
sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ),
);
# Optional data worth having: matching rules, "Fast add" framework (a short
# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values.
for my $f (qw(
marcflavour/marc21/optional/marc21_default_matching_rules.yml
marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml
optional/marc21_holdings_coded_values.yml
optional/marc21_relatorterms.yml
optional/auth_val.yml
optional/csv_profiles.yml
)) {
push @files, "$data/en/$f" if -f "$data/en/$f";
}
say "bootstrap: loading ", scalar(@files), " framework / data files...";
my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files );
for my $lvl (@$list) {
for my $f ( @{ $lvl->{fwklist} } ) {
say " ! $f->{fwkname}: $f->{error}" if $f->{error};
}
}
$installer->set_version_syspref();
$installer->set_languages_syspref('en');
# ---- Home-library seed data (YAML in the same format Koha ships) ----------
for my $y (sort glob("$seed_dir/*.yml")) {
my $err = $installer->load_sql($y);
say " seed $y: ", ( $err ? "ERROR $err" : 'ok' );
}
# ---- Library, patron categories, owner account ----------------------------
Koha::Library->new({
branchcode => $env{code}, branchname => $env{library},
})->store unless Koha::Libraries->find( $env{code} );
for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) {
Koha::Patron::Category->new({
categorycode => $c->[0], description => $c->[1], category_type => $c->[2],
enrolmentperiod => 999, overduenoticerequired => 0,
})->store unless Koha::Patron::Categories->find( $c->[0] );
}
my $admin = Koha::Patron->new({
cardnumber => '1000000', surname => 'Librarian', userid => $env{admin_user},
branchcode => $env{code}, categorycode => 'OWNER', flags => 1, # 1 = superlibrarian
})->store;
$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 });
# ---- Make "add a book" need as few decisions as possible -------------------
# The item type (942$c) is mandatory in Koha's frameworks; default it to Book so
# a Z39.50 import can be saved without touching that field.
$dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK'
WHERE tagfield = '942' AND tagsubfield = 'c'});
# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework
# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it
# to the catalogue's `ean` column (used by the quick-add page's duplicate check).
$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean'
WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')});
# ---- Extra item types and shelves chosen at install time -------------------
# KOHA_ITEM_TYPES / KOHA_SHELVES: "CODE=Description;CODE=Description". Koha has no
# API for adding shelves later, so they are declared here (more can be added in
# Administration -> Authorized values -> LOC).
sub pairs {
my ($raw) = @_;
return map { my ( $c, $d ) = split /=/, $_, 2; $c =~ s/[^A-Za-z0-9_]//g; ( uc $c, ( $d // $c ) =~ s/^\s+|\s+$//gr ) }
grep { /\S/ } split /;/, ( $raw // '' );
}
{
my @t = pairs( $ENV{KOHA_ITEM_TYPES} );
while ( my ( $c, $d ) = splice @t, 0, 2 ) {
next unless length $c;
Koha::ItemType->new( { itemtype => substr( $c, 0, 10 ), description => $d } )->store
unless Koha::ItemTypes->find( substr( $c, 0, 10 ) );
}
my @s = pairs( $ENV{KOHA_SHELVES} );
while ( my ( $c, $d ) = splice @s, 0, 2 ) {
next unless length $c;
Koha::AuthorisedValue->new( { category => 'LOC', authorised_value => substr( $c, 0, 80 ), lib => $d, lib_opac => $d } )->store
unless Koha::AuthorisedValues->search( { category => 'LOC', authorised_value => substr( $c, 0, 80 ) } )->count;
}
}
# ---- Lending rules --------------------------------------------------------
# A fresh Koha refuses every checkout ("No circulation rule is defined for this
# patron and itemtype combination") until a default rule exists. One catch-all
# rule for a home library: 3 weeks, 25 items, 3 renewals, no fines.
Koha::CirculationRules->set_rules({
branchcode => undef, categorycode => undef, itemtype => undef,
rules => {
issuelength => 21, lengthunit => 'days', maxissueqty => 25, maxonsiteissueqty => '',
renewalsallowed => 3, renewalperiod => 21, norenewalbefore => '', noautorenewalbefore => '',
auto_renew => 0, no_auto_renewal_after => '', no_auto_renewal_after_hard_limit => '',
unseen_renewals_allowed => '',
fine => 0, finedays => 0, firstremind => 0, chargeperiod => 0, chargeperiod_charge_at => 0,
overduefinescap => '', cap_fine_to_replacement_price => 0, rentaldiscount => 0,
maxsuspensiondays => '', suspension_chargeperiod => 1,
hardduedate => '', hardduedatecompare => -1,
reservesallowed => 5, holds_per_record => 1, holds_per_day => '', onshelfholds => 1,
opacitemholds => 'N', decreaseloanholds => '', article_requests => 'no',
recalls_allowed => undef, recalls_per_record => undef, on_shelf_recalls => undef,
recall_due_date_interval => undef, recall_overdue_fine => undef,
recall_shelf_time => undef, holds_pickup_period => undef,
},
});
# ---- Preferences ----------------------------------------------------------
my %prefs = (
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
autoMemberNum => 1, # new patrons get the next library-card number automatically
finesMode => 'off', # a home library does not charge overdue fines
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
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
OPACSearchForTitleIn => '',
BiblioDefaultView => 'normal',
opacuserlogin => 1,
OpacMaintenance => 0,
ExpireReservesMaxPickUpDelay => 0,
AllowOfflineCirculation => 0,
marcflavour => 'MARC21',
LibraryName => $env{library},
timezone => $ENV{TZ} || 'UTC',
);
C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs;
say "bootstrap: done. Staff login: $env{admin_user}";
View File
+55
View File
@@ -0,0 +1,55 @@
#!/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 );
}
+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
# Container entrypoint: (re)creates the Koha instance config from environment
# variables on every start, installs the database on the first start, then runs
# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha.
set -euo pipefail
INST="${KOHA_INSTANCE:-library}"
DB_HOST="${KOHA_DB_HOST:-db}"
DB_NAME="${KOHA_DB_NAME:-koha_library}"
DB_USER="${KOHA_DB_USER:-koha_library}"
: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}"
: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}"
STAFF_PORT=8081
export TZ="${TZ:-UTC}"
log() { echo "[koha-init] $*"; }
# Marker the container healthcheck waits for. Before the database exists Koha
# serves its web-installer page with HTTP 200, so "port answers" is not "ready".
rm -f /run/koha-ready
# --- 1. memcached (Koha caches sysprefs and sessions in it) -----------------
memcached -u memcache -m 64 -d
cron
# --- 2. wait for MariaDB ----------------------------------------------------
log "waiting for database at $DB_HOST..."
for i in $(seq 1 120); do
mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break
[ "$i" = 120 ] && { log "database never became ready"; exit 1; }
sleep 2
done
# --- 3. instance config (regenerated each time a container is created) ------
if [ ! -d "/etc/koha/sites/$INST" ]; then
log "creating Koha instance '$INST'..."
sed -i \
-e 's|^DOMAIN=.*|DOMAIN=""|' \
-e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \
-e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \
/etc/koha/koha-sites.conf
echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd
koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \
--memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true
# No RabbitMQ in this stack: workers fall back to polling the database.
CONF="/etc/koha/sites/$INST/koha-conf.xml"
perl -0pi -e 's|\s*<message_broker>.*?</message_broker>||s' "$CONF"
# Serve both interfaces through Plack (much faster than plain CGI).
SITE="/etc/apache2/sites-available/$INST.conf"
# The *-plack.conf files add the proxy rules on top of the plain ones (which
# still provide static files and rewrites), so enable them in addition.
sed -i \
-e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf| Include /etc/koha/apache-shared-opac-plack.conf|' \
-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
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
# (The staff and public catalogue sites cannot share one address: Koha gives both the
# same URL shapes. The staff site is the natural home for a home library.)
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
# 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
# instance user (Plack does the real work as that user already).
rm -f /etc/apache2/mods-enabled/mpm_itk.*
a2enmod mpm_prefork >/dev/null 2>&1 || true
sed -i '/AssignUserID/d' "$SITE"
sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \
-e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars
grep -q "^Listen $STAFF_PORT\b" /etc/apache2/ports.conf || echo "Listen $STAFF_PORT" >> /etc/apache2/ports.conf
grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf
fi
chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true
# --- 4. install or upgrade the database -------------------------------------
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
if [ "$TABLES" = "0" ]; then
log "first start: installing Koha (about a minute)..."
# (not koha-shell -c: it wraps the command in single quotes of its own)
runuser -u "$INST-koha" -- env \
KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \
KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" KOHA_LIBRARY_CODE="${KOHA_LIBRARY_CODE:-HOME}" \
KOHA_SHELVES="${KOHA_SHELVES:-}" KOHA_ITEM_TYPES="${KOHA_ITEM_TYPES:-}" TZ="$TZ" \
perl /opt/koha-init/bootstrap.pl
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
[ "$TABLES" -gt 0 ] || { log "ERROR: installation did not create the database"; exit 1; }
FIRST_RUN=1
else
log "existing database found - applying any pending upgrades..."
koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)"
FIRST_RUN=0
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,
# so they are started explicitly on every start) ------------------------
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
log "building search index..."
koha-rebuild-zebra -f -v "$INST" >/dev/null 2>&1 || true
fi
for svc in koha-zebra koha-indexer koha-worker koha-plack; do
"$svc" --restart "$INST" >/dev/null 2>&1 || "$svc" --start "$INST" >/dev/null 2>&1 \
|| log "WARNING: could not start $svc"
done
# koha-create / koha-plack may have started Apache already; run it in the foreground instead.
apache2ctl -k stop >/dev/null 2>&1 || true
for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done
rm -f /var/run/apache2/apache2.pid
touch /run/koha-ready
log "ready - library catalogue on :80, staff interface on :$STAFF_PORT"
exec apache2ctl -D FOREGROUND
+53
View File
@@ -0,0 +1,53 @@
#!/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
@@ -0,0 +1,44 @@
#!/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
@@ -0,0 +1,42 @@
#!/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 => "$@" } );
+13
View File
@@ -0,0 +1,13 @@
---
description:
- "Item types for a home library: books, movies, music, games"
tables:
- itemtypes:
translatable: [ description ]
multiline: []
rows:
- { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" }
- { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" }
- { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" }
- { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" }
- { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" }
+9
View File
@@ -0,0 +1,9 @@
---
description:
- "Public Z39.50 servers used by Cataloging > Z39.50/SRU search"
tables:
- z3950servers:
translatable: [ servername ]
multiline: []
rows:
- { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio }
+116
View File
@@ -0,0 +1,116 @@
---
description:
- "Label sheets and layouts for Tools > Labels (spine labels and barcodes)"
tables:
- creator_templates:
translatable: [ template_desc ]
multiline: [ ]
rows:
# Avery 5160 / 8160 - the common 30-up address sheet; fits a spine label
# plus barcode on one label.
- template_code: "Avery 5160 | 1 x 2-5/8 (30 per sheet)"
template_desc: "3 columns, 10 rows. Spine label with barcode."
page_width: 8.5
page_height: 11
label_width: 2.63
label_height: 1
top_text_margin: 0.139
left_text_margin: 0.1
top_margin: 0.35
left_margin: 0.23
cols: 3
rows: 10
col_gap: 0.13
row_gap: 0
units: "INCH"
creator: "Labels"
# Avery 5167 / 8167 - 80-up small labels, good for barcode-only stickers.
- template_code: "Avery 5167 | 1/2 x 1-3/4 (80 per sheet)"
template_desc: "4 columns, 20 rows. Barcode-only stickers."
page_width: 8.5
page_height: 11
label_width: 1.75
label_height: 0.5
top_text_margin: 0.1
left_text_margin: 0.05
top_margin: 0.5
left_margin: 0.3
cols: 4
rows: 20
col_gap: 0.3
row_gap: 0
units: "INCH"
creator: "Labels"
# Demco 1" x 1.5" spine labels (Koha's own sample measurements).
- template_code: "Demco spine | 1 x 1.5 (50 per sheet)"
template_desc: "5 columns, 10 rows. Dewey / call number on up to four lines."
page_width: 8.5
page_height: 11
label_width: 1.5
label_height: 1
top_text_margin: 0.236
left_text_margin: 0
top_margin: 0.5
left_margin: 0.25
cols: 5
rows: 10
col_gap: 0.0625
row_gap: 0
units: "INCH"
creator: "Labels"
- creator_layouts:
translatable: [ layout_name, layout_xml ]
multiline: [ layout_xml ]
rows:
# callnum_split splits "613.2 SMI" onto separate lines the way spine
# labels are normally read (Dewey/LC aware).
- layout_name: "Spine - call number"
barcode_type: CODE39
start_label: 1
printing_type: BIB
guidebox: 0
oblique_title: 0
font: TB
font_size: 11
units: POINT
callnum_split: 1
text_justify: C
format_string: "itemcallnumber"
layout_xml:
- "<opt></opt>"
creator: "Labels"
- layout_name: "Spine + barcode"
barcode_type: CODE39
start_label: 1
printing_type: BIBBAR
guidebox: 0
oblique_title: 0
font: TB
font_size: 10
units: POINT
callnum_split: 1
text_justify: C
format_string: "itemcallnumber"
layout_xml:
- "<opt></opt>"
creator: "Labels"
- layout_name: "Barcode only"
barcode_type: CODE39
start_label: 1
printing_type: BAR
guidebox: 0
oblique_title: 0
font: TR
font_size: 10
units: POINT
callnum_split: 0
text_justify: L
format_string: "barcode"
layout_xml:
- "<opt></opt>"
creator: "Labels"
+52
View File
@@ -0,0 +1,52 @@
---
description:
- "Library card sheet and layout for Tools > Patron card creator"
tables:
- creator_templates:
translatable: [ template_desc ]
multiline: [ ]
rows:
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
template_desc: "Library cards: 2 columns, 5 rows."
page_width: 8.5
page_height: 11
label_width: 3.5
label_height: 2
top_text_margin: 0
left_text_margin: 0
top_margin: 0.5
left_margin: 0.75
cols: 2
rows: 5
col_gap: 0
row_gap: 0
units: "INCH"
creator: "Patroncards"
- creator_layouts:
translatable: [ layout_name, layout_xml ]
multiline: [ layout_xml ]
rows:
# Name and library at the top, card number as a scannable barcode.
- layout_name: "Library card"
barcode_type: CODE39
start_label: 1
printing_type: BAR
guidebox: 0
oblique_title: 0
font: TR
font_size: 10
units: POINT
callnum_split: 0
text_justify: L
format_string: "barcode"
layout_xml:
- "<opt page_side=\"F\" units=\"POINT\">"
- " <text>&lt;firstname&gt; &lt;surname&gt;</text>"
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
- " <text>Library card</text>"
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
- "</opt>"
creator: "Patroncards"
+28
View File
@@ -0,0 +1,28 @@
# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python
# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds
# one small layer instead of another base-image pull.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
# you are behind a TLS-inspecting proxy. Empty by default.
COPY certs/ /usr/local/share/ca-certificates/extra/
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 useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
COPY app.py index.html /app/
USER quickadd
WORKDIR /app
EXPOSE 8082
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)"
CMD ["python3", "/app/app.py"]
Binary file not shown.
File diff suppressed because it is too large Load Diff
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
sure the running Quick Add reads them (at the size the page sends, 2400 px).
python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
"""
import base64, io, json, os, sys, urllib.request
from PIL import Image, ImageOps
url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
def scan(data, kind):
req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
headers={"Content-Type": "image/jpeg", "Authorization": auth})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)["codes"]
def shrunk(path, m=2400): # what the page does before uploading
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
k = min(1, m / max(im.size))
im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
bad = 0
for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
base = n[:-4]
exp_file = os.path.join(D, base + ".expect")
exp = open(exp_file).read() if os.path.exists(exp_file) else None
codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
ok = (codes[:1] == [exp]) if exp else (codes == [])
bad += not ok
print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
sys.exit(1 if bad else 0)
+77
View File
@@ -0,0 +1,77 @@
"""Generate phone-photo-like test images containing real barcodes."""
import random, io, os
import zxingcpp
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
os.makedirs(OUT, exist_ok=True)
random.seed(7)
def barcode(fmt, text, scale=4, height=90):
bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
"""Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
d = ImageDraw.Draw(bg)
for _ in range(60): # clutter: stripes, blobs, text-ish bars
x, y = random.randint(0, w), random.randint(0, h)
d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
fill=tuple(random.randint(0, 255) for _ in range(3)))
label = code_img.copy()
k = (w * size_frac) / label.width
label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
pad.paste(label, (30, 30))
if tilt:
pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
if blur:
bg = bg.filter(ImageFilter.GaussianBlur(blur))
bg = ImageEnhance.Contrast(bg).enhance(contrast)
bg = ImageEnhance.Brightness(bg).enhance(bright)
if noise:
px = bg.load()
for _ in range(noise):
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
v = random.randint(0, 255); px[x, y] = (v, v, v)
buf = io.BytesIO()
kw = {"quality": quality}
if exif is not None:
e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
bg.save(buf, "JPEG", **kw)
return buf.getvalue()
E13 = zxingcpp.BarcodeFormat.EAN13
UPCA = zxingcpp.BarcodeFormat.UPCA
C39 = zxingcpp.BarcodeFormat.Code39
cases = {
# name: (expected, bytes)
"isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
"isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
"isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
"isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
"isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
"isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
"isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
"upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
}
for name, (exp, data) in cases.items():
open(f"{OUT}/{name}.jpg", "wb").write(data)
open(f"{OUT}/{name}.expect", "w").write(exp)
# sticker (Code 39, 7 digits) and a photo with no barcode at all
open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
for n in sorted(os.listdir(OUT)):
if n.endswith(".jpg"):
print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")
+329
View File
@@ -0,0 +1,329 @@
#!/bin/bash
################################################################################
# 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.
#
# What gets installed (all from this checkout's library/ directory, deployed to
# $LIBRARY_DIR - see library/README.md for the stack itself):
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
# - MariaDB.
# 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
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
# all, because Koha's own search permission also allows editing label layouts); the page
# list stores that guest login like any other Basic Auth site. The admin login never
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
#
# Whether other devices on the LAN can reach the library site is the "Network access"
# 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.
################################################################################
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
################################################################################
# Detection / state
################################################################################
library_env_get() {
sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
}
library_is_installed() {
[[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1
[[ "$(docker_container_state library-koha)" != "missing" ]]
}
library_is_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() {
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)/"
}
# True when the kiosk's page list already shows the catalogue.
library_tab_exists() {
local url idx
url="$(library_catalogue_url)"
for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" ]] && return 0
done
return 1
}
addon_library_status() {
if ! library_is_installed; then
echo "Library Catalog: not installed"
return
fi
local state health ip
state="$(docker_container_state library-koha)"
health="$(docker_container_health library-koha)"
ip="$(get_ip_address)"
echo "Library Catalog: ${state}${health:+ ($health)}"
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
echo " Network: this kiosk only"
else
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
fi
}
addon_library_menu_builder() {
if library_is_installed; then
load_existing_config
local tab_label="Show Find a book on the kiosk"
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
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)
else
MENU_LABELS=("Install Library Catalog")
MENU_HANDLERS=(action_library_install)
fi
}
addon_library_menu() {
run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status
}
################################################################################
# Helpers
################################################################################
# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the
# deployed .env (passwords) even if a stray one exists in the checkout.
library_sync_files() {
local src="${SCRIPT_DIR}/library"
if [[ ! -f "$src/docker-compose.yml" ]]; then
log_error "library/ not found in this checkout ($src) - re-clone or git pull"
return 1
fi
sudo mkdir -p "$LIBRARY_DIR" || return 1
tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1
sudo chmod 755 "$LIBRARY_DIR/install.sh"
}
library_add_tab() {
load_existing_config
if library_tab_exists; then
log_info "Find a book is already one of the kiosk's pages."
return 0
fi
URLS+=("$(library_catalogue_url)")
DURS+=("0")
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
save_config
}
# Drop every page that points at the catalogue's local address.
library_remove_tab() {
load_existing_config
local url old idx
url="$(library_catalogue_url)"
old="$(library_old_catalogue_url)"
local -a nu=() nd=() nus=() np=() nn=()
for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
done
if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then
return 0
fi
URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}")
HOME_TAB_INDEX=-1
save_config
}
# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers.
library_set_bind() {
local addr="$1"
if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then
sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env"
else
echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null
fi
docker_compose "$LIBRARY_DIR" up -d
}
library_warn_if_low_memory() {
local total_mb
total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)
if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then
log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow."
fi
}
################################################################################
# Actions
################################################################################
action_library_install() {
echo
echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label"
echo "printing and a scan-to-add page. It needs Docker (installed for you if"
echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes"
echo "5-10 minutes."
echo
library_warn_if_low_memory
local library_name
library_name=$(ask_text "Library name (shown on the catalogue)" "My Library")
[[ -z "$library_name" ]] && library_name="My Library"
local bind="0.0.0.0"
echo
echo "Who should be able to open the catalogue, staff interface and Quick Add"
echo "page besides this kiosk?"
echo " - Yes: any device on your network (use it from your own laptop/phone;"
echo " the staff pages are protected by a generated password)."
echo " - No: only this kiosk. Safest, but you can only manage the library here."
if ! ask_yes_no "Allow other devices on the network?" "y"; then
bind="127.0.0.1"
fi
docker_ensure_installed || { pause; return 1; }
library_sync_files || { pause; return 1; }
echo
log_info "Building and starting the stack - this takes a while on the first run..."
if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then
log_error "The library stack failed to start - see: sudo docker logs library-koha"
pause
return 1
fi
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
library_add_tab
fi
echo
log_success "Library Catalog installed"
pause
}
action_library_show_access() {
local ip user pass
ip="$(get_ip_address)"
user="$(library_env_get ADMIN_USER)"
pass="$(library_env_get ADMIN_PASSWORD)"
echo
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
ip="localhost"
echo "(Network access is limited to this kiosk.)"
fi
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
echo " Admin login: ${user} / ${pass}"
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
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 " Passwords live in $LIBRARY_DIR/.env (root only)."
pause
}
action_library_toggle_tab() {
load_existing_config
if library_tab_exists; then
library_remove_tab
else
library_add_tab
fi
pause
}
action_library_network() {
local current
current="$(library_env_get BIND_ADDR)"
echo
echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")"
echo
if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then
library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect"
else
library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now"
fi
pause
}
action_library_start() {
docker_compose "$LIBRARY_DIR" start && log_success "Started"
pause
}
action_library_stop() {
docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)"
pause
}
action_library_update() {
echo
echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept."
if ! ask_yes_no "Update now?" "y"; then
return 0
fi
docker_ensure_installed || { pause; return 1; }
library_sync_files || { pause; return 1; }
if sudo "$LIBRARY_DIR/install.sh" --update; then
log_success "Updated"
else
log_error "Update failed - see: sudo docker logs library-koha"
fi
pause
}
# library_do_uninstall [purge]
# no argument: stop and remove the containers but KEEP the data volumes and
# $LIBRARY_DIR/.env - the database password is baked into the
# database volume, so a later reinstall needs the same .env.
# purge: also delete the data (all books!), the images and $LIBRARY_DIR.
library_do_uninstall() {
local mode="${1:-}"
library_remove_tab
if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then
if [[ "$mode" == "purge" ]]; then
docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true
else
docker_compose "$LIBRARY_DIR" down 2>/dev/null || true
fi
fi
if [[ "$mode" == "purge" ]]; then
sudo rm -rf "$LIBRARY_DIR"
log_success "Library Catalog and all its data removed"
else
log_success "Library Catalog stopped and removed (your books are kept)"
fi
}
action_library_uninstall() {
echo
echo "This stops and removes the catalogue containers."
if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then
local confirm
confirm=$(ask_text "Type DELETE to confirm" "")
if [[ "$confirm" != "DELETE" ]]; then
echo "Cancelled"
pause
return 0
fi
library_do_uninstall purge
else
library_do_uninstall
echo "Your books are kept; install again from this menu to bring them back."
fi
pause
}
+145 -4
View File
@@ -1,7 +1,7 @@
#!/bin/bash
################################################################################
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
# Tailscale, Netbird).
# Tailscale, Netbird) plus SSH Access (GitHub/Launchpad key import).
#
# Third Addon migrated, and the biggest so far in scope (4 sub-areas).
# All four genuinely mutate real system state at fixed paths this project
@@ -31,6 +31,7 @@ VNC_APT_PACKAGES=(x11vnc)
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
remote_access_status() {
echo "SSH: $(ssh_status_line)"
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")"
@@ -38,8 +39,8 @@ remote_access_status() {
}
remote_access_menu_builder() {
MENU_LABELS=("VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
MENU_HANDLERS=(vnc_menu wireguard_menu tailscale_menu netbird_menu)
MENU_LABELS=("SSH Access (GitHub/Launchpad keys)" "VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
MENU_HANDLERS=(ssh_menu vnc_menu wireguard_menu tailscale_menu netbird_menu)
}
remote_access_menu() {
@@ -414,7 +415,7 @@ action_netbird_connect() {
return
fi
if sudo netbird up --setup-key "$setup_key"; then
if sudo netbird up --setup-key "$setup_key" --allow-server-ssh; then
log_success "Netbird connected"
else
log_error "Netbird connection failed"
@@ -438,3 +439,143 @@ netbird_do_uninstall() {
sudo apt remove -y netbird 2>/dev/null || true
log_success "Netbird removed"
}
################################################################################
# SSH Access - openssh-server plus public keys imported from GitHub or
# Launchpad with ssh-import-id (the same "Import SSH identity" choice
# Ubuntu's own installer offers). Keys go to the admin user running this
# menu (~/.ssh/authorized_keys), never to the restricted `kiosk` user.
# The key sets published by those services are fetched over HTTPS and
# are public data, so no secret is handled here.
################################################################################
SSH_KEYS_ONLY_CONF="${SSHD_CONFIG_DIR:-/etc/ssh/sshd_config.d}/10-kiosk-keys-only.conf"
SSH_AUTH_KEYS="${SSH_AUTH_KEYS:-$HOME/.ssh/authorized_keys}"
ssh_key_count() {
[[ -f "$SSH_AUTH_KEYS" ]] && grep -cE '^(ssh-|ecdsa-|sk-)' "$SSH_AUTH_KEYS" || echo 0
}
ssh_status_line() {
local state="not installed"
if is_service_active ssh || is_service_active sshd; then
state="running"
elif dpkg -s openssh-server &>/dev/null; then
state="installed, not running"
fi
local pw="password login on"
[[ -f "$SSH_KEYS_ONLY_CONF" ]] && pw="keys only"
echo "$state, $(ssh_key_count) key(s), $pw"
}
ssh_status() {
echo "SSH: $(ssh_status_line)"
echo " Keys go to: $SSH_AUTH_KEYS"
}
ssh_menu_builder() {
MENU_LABELS=("Enable SSH server" "Import keys from GitHub" "Import keys from Launchpad" "List authorized keys" "Require keys (disable password login)" "Allow password login again")
MENU_HANDLERS=(action_ssh_enable action_ssh_import_github action_ssh_import_launchpad action_ssh_list_keys action_ssh_keys_only action_ssh_allow_passwords)
}
ssh_menu() {
run_menu "SSH ACCESS" ssh_menu_builder ssh_status
}
action_ssh_enable() {
echo
sudo apt install -y openssh-server ssh-import-id || { log_error "Could not install openssh-server"; return 1; }
sudo systemctl enable --now ssh || { log_error "Could not start the SSH server"; return 1; }
# provision_configure_firewall already allows ssh; harmless if repeated.
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
sudo ufw allow ssh >/dev/null
fi
log_success "SSH server enabled"
}
# $1 = "gh" or "lp", $2 = label for messages.
ssh_import_from() {
local prefix="$1" label="$2" user
echo
if ! command -v ssh-import-id &>/dev/null; then
sudo apt install -y ssh-import-id || { log_error "Could not install ssh-import-id"; return 1; }
fi
read -r -p "$label username (blank to cancel): " user
[[ -z "$user" ]] && { echo "Cancelled"; return; }
if [[ ! "$user" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
log_error "That doesn't look like a valid $label username"
return 1
fi
if ssh-import-id "${prefix}:${user}"; then
log_success "Imported $label keys for $user ($(ssh_key_count) key(s) now authorized)"
else
log_error "Import failed - check the username and that this machine can reach $label"
return 1
fi
}
action_ssh_import_github() { ssh_import_from gh "GitHub"; }
action_ssh_import_launchpad() { ssh_import_from lp "Launchpad"; }
action_ssh_list_keys() {
echo
if [[ "$(ssh_key_count)" == "0" ]]; then
echo "No authorized keys for $USER yet."
else
ssh-keygen -lf "$SSH_AUTH_KEYS"
fi
}
action_ssh_keys_only() {
echo
if [[ "$(ssh_key_count)" == "0" ]]; then
log_error "No authorized keys yet - import some first, or you'd lock yourself out"
return 1
fi
echo "Log in from another machine with a key BEFORE doing this; password"
echo "logins over SSH will stop working."
ask_yes_no "Disable SSH password login?" "n" || { echo "Cancelled"; return; }
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' |
sudo install -D -m 644 /dev/stdin "$SSH_KEYS_ONLY_CONF"
if sudo sshd -t && sudo systemctl reload ssh; then
log_success "SSH now requires a key"
else
sudo rm -f "$SSH_KEYS_ONLY_CONF"
log_error "sshd rejected the config - reverted"
return 1
fi
}
action_ssh_allow_passwords() {
echo
sudo rm -f "$SSH_KEYS_ONLY_CONF"
sudo systemctl reload ssh 2>/dev/null || true
log_success "SSH password login allowed again"
}
# First-time install hook (called from lib/provision.sh).
ssh_first_time_setup() {
echo
echo "SSH lets you manage the kiosk remotely. Ubuntu can import your"
echo "public keys from GitHub or Launchpad so you can log in with no password."
ask_yes_no "Enable SSH and import keys now?" "y" || { log_info "Configure later: Addons -> Remote Access -> SSH Access"; return; }
action_ssh_enable || return 0
local choice
while true; do
echo
echo "Import keys from:"
echo " 1. GitHub"
echo " 2. Launchpad"
echo " 3. Done"
choice=$(ask_integer "Choose" "3" 1 3)
case "$choice" in
1) action_ssh_import_github || true ;;
2) action_ssh_import_launchpad || true ;;
*) break ;;
esac
done
if [[ "$(ssh_key_count)" != "0" ]]; then
echo "Tip: Remote Access -> SSH Access can switch SSH to key-only login"
echo "once you've confirmed a key works."
fi
}
+8
View File
@@ -65,6 +65,7 @@ clone_detect_addons() {
command -v netbird &>/dev/null && addons+=("netbird")
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
webui_is_installed 2>/dev/null && addons+=("webui")
library_is_installed 2>/dev/null && addons+=("library")
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
local authelia_url
@@ -243,6 +244,13 @@ action_apply_clone_settings() {
fi ;;
asterisk_intercom)
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
library)
if library_is_installed; then
echo " [x] Library Catalog - already installed (the books themselves are not cloned)"
else
echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)"
missing_any=true
fi ;;
authelia)
echo " [!] Authelia - needs a fresh login on this device" ;;
*)
+3 -1
View File
@@ -7,7 +7,7 @@
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
# webui_do_uninstall, power_schedule_do_remove_all,
# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all,
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
# re-implementing removal logic for each addon a second time here - if an
# addon's uninstall logic changes, this picks it up automatically. Only
@@ -59,6 +59,7 @@ action_complete_uninstall() {
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
echo " • Asterisk Intercom (Baresip)"
echo " • Web UI"
echo " • Library Catalog (Koha) and ALL the books in it"
echo " • LightDM and Openbox"
echo " • All kiosk schedules and services"
echo " • Emergency hotspot configuration"
@@ -90,6 +91,7 @@ action_complete_uninstall() {
squeezelite_do_uninstall
asterisk_intercom_do_uninstall purge
webui_do_uninstall
library_do_uninstall purge
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
power_schedule_do_remove_all
+173 -7
View File
@@ -34,6 +34,150 @@
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
# in every file and waits for that device - 90s, then the whole apply
# fails - if one names an interface this machine doesn't have (the
# installer's own config can carry e.g. "wlan0" while the adapter here is
# "wlp1s0"). Take such entries out of the other netplan files first
# (originals kept as *.kiosk-bak, which netplan ignores).
wifi_drop_stale_entries() {
local f
for f in "$NETPLAN_DIR"/*.yaml; do
[[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
import os, shutil, sys, yaml
p = sys.argv[1]
try:
d = yaml.safe_load(open(p)) or {}
w = (d.get("network") or {}).get("wifis") or {}
stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
if stale:
shutil.copy2(p, p + ".kiosk-bak")
for n in stale:
del w[n]
if not w:
del d["network"]["wifis"]
# Not "optional" = the boot waits (up to ~2 min) for it to get an address.
opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
for n in opt:
w[n]["optional"] = True
if stale or opt:
if not stale:
shutil.copy2(p, p + ".kiosk-bak")
mode = os.stat(p).st_mode & 0o777
with open(p, "w") as fh:
yaml.safe_dump(d, fh, default_flow_style=False)
os.chmod(p, mode)
print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
except Exception:
pass
PYEOF
done
}
# Why a connect attempt failed, from wpa_supplicant's own log lines in
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
# short human-readable reason, or nothing if the log says nothing useful.
wifi_diagnose() {
local log
log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
echo "wrong password (the network rejected the key)"
elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
echo "network not found - wrong name, or out of range of this adapter"
elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
fi
}
# Every wireless interface, one per line.
wifi_list_ifaces() {
local d
for d in /sys/class/net/*; do
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
basename "$d"
fi
done
}
# "USB" / "PCI" / "" - what an interface hangs off, to tell a dongle
# from a built-in card when there are two.
wifi_iface_bus() {
local sub
sub=$(readlink -f "/sys/class/net/$1/device/subsystem" 2>/dev/null) || return 0
case "$sub" in
*/usb) echo "USB" ;;
*/pci) echo "PCI" ;;
esac
}
# The interface to configure: the only one, or the user's pick when there
# are several (e.g. built-in card plus a USB dongle).
wifi_choose_iface() {
local -a ifaces
mapfile -t ifaces < <(wifi_list_ifaces)
if (( ${#ifaces[@]} == 0 )); then
wifi_detect_iface
return
fi
if (( ${#ifaces[@]} == 1 )); then
echo "${ifaces[0]}"
return 0
fi
{
echo "Several WiFi adapters found:"
local i bus
for i in "${!ifaces[@]}"; do
bus=$(wifi_iface_bus "${ifaces[$i]}")
printf ' %d. %s%s\n' "$((i + 1))" "${ifaces[$i]}" "${bus:+ ($bus)}"
done
} >&2
local choice
read -r -p "Which one? [1]: " choice </dev/tty >&2
choice=${choice:-1}
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ifaces[@]} )); then
echo "${ifaces[$((choice - 1))]}"
else
echo "${ifaces[0]}"
fi
}
# What the adapter is actually doing, printed after a failed connect so the
# cause (regulatory domain, weak signal, DHCP, driver) is visible, not guessed.
wifi_debug_dump() {
local i="$1"
echo "--- link ---"
iw dev "$i" link 2>&1 | head -8
echo "--- regulatory domain ---"
iw reg get 2>&1 | grep -m2 "country\|global" || true
echo "--- wpa_supplicant log (last 15) ---"
journalctl -b -u "netplan-wpa-$i" -n 15 --no-pager -o cat 2>&1 | cut -c1-160
echo "--- networkd ---"
networkctl status "$i" --no-pager 2>&1 | head -12
echo "(full log: /var/lib/kiosk-firstboot/install.log, or journalctl -b -u 'netplan-wpa-*')"
}
# Two-letter country code for the WiFi regulatory domain: the one already
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
# Without a country set, Linux uses the restrictive "world" domain: many
# 5 GHz channels become listen-only, so a network shows up in scans but
# the adapter can't join it.
wifi_default_country() {
local c=""
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
fi
if [[ -z "$c" && -r /etc/modprobe.d/kiosk-regdom.conf ]]; then
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' /etc/modprobe.d/kiosk-regdom.conf | head -1)
fi
if [[ -z "$c" ]]; then
local loc="${LANG:-}"
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
fi
echo "${c^^}"
}
wifi_menu() {
local rc=0
wifi_configure || rc=$?
@@ -131,7 +275,7 @@ wifi_configure() {
fi
local wifi_iface
if ! wifi_iface=$(wifi_detect_iface); then
if ! wifi_iface=$(wifi_choose_iface); then
log_warning "No WiFi hardware detected"
echo "Network interfaces found: $(ls /sys/class/net 2>/dev/null | tr '\n' ' ')"
echo "If you have a USB WiFi adapter, make sure it's plugged in. A built-in"
@@ -228,7 +372,17 @@ wifi_configure() {
return 1
fi
apply_wifi_config "$wifi_iface" "$ssid" "$password"
local country c_in
country=$(wifi_default_country)
read -r -p "Country code for WiFi regulations (2 letters, e.g. US) [${country:-none}]: " c_in
c_in=${c_in:-$country}
c_in=${c_in^^}
if [[ -n "$c_in" && ! "$c_in" =~ ^[A-Z]{2}$ ]]; then
log_warning "'$c_in' isn't a 2-letter country code - skipping it"
c_in=""
fi
apply_wifi_config "$wifi_iface" "$ssid" "$password" "$c_in"
}
# apply_wifi_config IFACE SSID PASSWORD
@@ -238,6 +392,7 @@ apply_wifi_config() {
local wifi_iface="$1"
local ssid="$2"
local password="$3"
local country="${4:-}"
# Our own file, merged by netplan with the others (wired config etc).
local netplan_file="$WIFI_NETPLAN_FILE"
@@ -261,7 +416,8 @@ network:
dhcp4: true
dhcp6: false
optional: true
access-points:
${country:+ regulatory-domain: \"$country\"
} access-points:
$(wifi_yaml_quote "$ssid"):
password: $(wifi_yaml_quote "$password")
EOF
@@ -293,14 +449,16 @@ WATCHEOF
sudo chmod 0600 "$netplan_file"
rm -f "$temp_plan"
sudo bash -c "$(declare -f wifi_drop_stale_entries); NETPLAN_DIR='$NETPLAN_DIR' WIFI_NETPLAN_FILE='$netplan_file' wifi_drop_stale_entries"
[[ -n "$country" ]] && sudo iw reg set "$country" 2>/dev/null
echo "Applying configuration..."
local netplan_log
netplan_log=$(mktemp)
if sudo netplan apply > "$netplan_log" 2>&1; then
cat "$netplan_log"
echo "Waiting for $wifi_iface to connect (up to 30 seconds)..."
echo "Waiting for $wifi_iface to connect (up to 45 seconds)..."
local _
for _ in {1..30}; do
for _ in {1..45}; do
wifi_iface_has_ip "$wifi_iface" && break
sleep 1
done
@@ -309,8 +467,16 @@ WATCHEOF
[[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert"
else
log_warning "Config applied but $wifi_iface has no address yet"
echo "Wrong password or network name is the usual cause. Details:"
echo " sudo journalctl -b -u 'netplan-wpa-*' -u systemd-networkd -n 30 --no-pager"
local why
why=$(wifi_diagnose "$wifi_iface")
if [[ -n "$why" ]]; then
log_error "Couldn't connect to $ssid: $why"
elif iw dev "$wifi_iface" link 2>/dev/null | grep -q "^Connected"; then
log_error "Joined the network but got no address - the router's DHCP isn't answering (or is slow)"
else
echo "Not joined, and the log doesn't say why - not necessarily the password."
fi
wifi_debug_dump "$wifi_iface"
fi
else
log_error "netplan apply failed"
@@ -0,0 +1,11 @@
# Keep WiFi adapters awake. Power saving (the adapter dozing between
# packets, and the USB port suspending an idle dongle) is what makes
# cheap dongles like the Realtek RTL8188CUS drop or stall a connection.
# Both are ordinary software settings, so nothing here can harm the
# hardware: the cost is a fraction of a watt.
# Radio power save off on every WiFi interface as it appears.
ACTION=="add", SUBSYSTEM=="net", KERNEL=="wl*", RUN+="/usr/sbin/iw dev $name set power_save off"
# No USB autosuspend for the common RTL8188/8192 CU/CUS/RU dongles.
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="8176|8177|8178|8179|817a|817b|817c|817d|817e|8191", TEST=="power/control", ATTR{power/control}="on"
+193 -2
View File
@@ -11,6 +11,11 @@
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
# kiosk-wifi-helper region-status
# {"country","locale","keyboard","locales":[],"keyboards":[]}
# kiosk-wifi-helper region-set stdin {"country"?,"locale"?,"keyboard"?} -> {"ok",...}
# WiFi country (regulatory domain), system
# locale and keyboard layout; every field optional
#
# Every reply is one JSON line on stdout, exit 0 on success. connect
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
@@ -24,6 +29,8 @@ set -uo pipefail
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
REGDOM_CONF="${KIOSK_REGDOM_CONF:-/etc/modprobe.d/kiosk-regdom.conf}"
LOCALE_FILE="${KIOSK_LOCALE_FILE:-/etc/default/locale}"
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
@@ -107,11 +114,143 @@ except Exception:
pass' "$WIFI_NETPLAN_FILE" 2>/dev/null
}
# Why a connect attempt failed, from wpa_supplicant's own log lines in
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
# short human-readable reason, or nothing if the log says nothing useful.
diagnose_failure() {
local log
log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
echo "wrong password (the network rejected the key)"
elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
echo "network not found - wrong name, or out of range of this adapter"
elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
fi
}
# Prefer the adapter our netplan file already configures (if it's still
# there) over "first one found", so a built-in card plus a USB dongle
# don't swap places between calls.
prefer_configured_iface() {
[[ -f "$WIFI_NETPLAN_FILE" ]] || return 1
python3 -I -c '
import sys, os, yaml
try:
d = yaml.safe_load(open(sys.argv[1]))
for n in (d.get("network", {}).get("wifis") or {}):
if os.path.exists(sys.argv[2] + "/net/" + str(n)):
print(n); sys.exit(0)
except Exception:
pass
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
}
# Two-letter country code for the WiFi regulatory domain: the one already
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
# Without a country set, Linux uses the restrictive "world" domain: many
# 5 GHz channels become listen-only, so a network shows up in scans but
# the adapter can't join it.
default_country() {
local c=""
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
fi
if [[ -z "$c" && -f "$REGDOM_CONF" ]]; then
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' "$REGDOM_CONF" | head -1)
fi
if [[ -z "$c" ]]; then
local loc="${LANG:-}"
[[ -r "$LOCALE_FILE" ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1)
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
fi
echo "${c^^}"
}
need_iface() {
IFACE=$(detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
IFACE=$(prefer_configured_iface || detect_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"
}
# ---- Region: WiFi country, system locale, keyboard layout -----------------
current_locale() {
[[ -r "$LOCALE_FILE" ]] || return 0
sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1
}
current_keyboard() {
localectl status 2>/dev/null | sed -n 's/^[[:space:]]*X11 Layout:[[:space:]]*//p' | head -1
}
# Locales that are generated on this system (setting any other one would
# leave the whole system on a locale that doesn't exist).
list_locales() {
locale -a 2>/dev/null | grep -i 'utf-\?8' | sed 's/\.[Uu][Tt][Ff]-\?8$/.UTF-8/' | sort -u
}
list_keyboards() {
localectl list-x11-keymap-layouts 2>/dev/null
}
do_region_status() {
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
--argjson ls "$(list_locales | jq -R . | jq -cs .)" \
--argjson ks "$(list_keyboards | jq -R . | jq -cs .)" \
'{ok:true,country:$c,locale:$l,keyboard:$k,locales:$ls,keyboards:$ks}'
}
# Persist the WiFi country: kernel option for the regulatory domain (so it
# applies from boot, before any WiFi config exists), the netplan file if
# there is one (so wpa_supplicant gets it too), and now via iw.
set_country() {
local c="$1"
printf '# Written by kiosk-wifi-helper (Region setting)\noptions cfg80211 ieee80211_regdom=%s\n' "$c" > "$REGDOM_CONF"
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
sed -i '/^[[:space:]]*regulatory-domain:/d' "$WIFI_NETPLAN_FILE"
sed -i "s/^\([[:space:]]*\)optional: true\$/&\n\1regulatory-domain: \"$c\"/" "$WIFI_NETPLAN_FILE"
fi
iw reg set "$c" 2>/dev/null || true
}
do_region_set() {
local input country locale keyboard
input=$(head -c 4096)
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"
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
country=${country^^}
if [[ -n "$country" ]]; then
[[ "$country" =~ ^[A-Z]{2}$ ]] || reply_error "A country code is 2 letters, e.g. US"
# Reject codes that aren't real countries when the tz database is here.
if [[ -r /usr/share/zoneinfo/iso3166.tab ]] && ! grep -q "^$country[[:space:]]" /usr/share/zoneinfo/iso3166.tab; then
reply_error "$country isn't a known country code"
fi
fi
if [[ -n "$locale" ]]; then
list_locales | grep -qxF -- "$locale" || reply_error "Locale $locale isn't installed on this system"
fi
if [[ -n "$keyboard" ]]; then
list_keyboards | grep -qxF -- "$keyboard" || reply_error "Unknown keyboard layout: $keyboard"
fi
local err
[[ -n "$country" ]] && set_country "$country"
if [[ -n "$locale" ]]; then
err=$(localectl set-locale "LANG=$locale" 2>&1) || reply_error "Couldn't set the locale: ${err:0:200}"
fi
if [[ -n "$keyboard" ]]; then
err=$(localectl set-x11-keymap "$keyboard" 2>&1) || reply_error "Couldn't set the keyboard: ${err:0:200}"
# Console layout too, when there's a console map of the same name.
localectl list-keymaps 2>/dev/null | grep -qxF -- "$keyboard" && localectl set-keymap "$keyboard" >/dev/null 2>&1
fi
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
--argjson restart "$([[ -n "$locale$keyboard" ]] && echo true || echo false)" \
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
}
do_status() {
local iface ssid="" ip="" configured
configured=$(configured_ssid)
@@ -168,6 +307,47 @@ do_scan() {
| {ok: true, iface: $i, networks: .}' < "$TMP_SCAN"
}
# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
# in every file and waits for that device - 90s, then the whole apply
# fails - if one names an interface this machine doesn't have (the
# installer's own config can carry e.g. "wlan0" while the adapter here is
# "wlp1s0"). Take such entries out of the other netplan files first
# (originals kept as *.kiosk-bak, which netplan ignores).
drop_stale_entries() {
local f
for f in "$NETPLAN_DIR"/*.yaml; do
[[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
import os, shutil, sys, yaml
p = sys.argv[1]
try:
d = yaml.safe_load(open(p)) or {}
w = (d.get("network") or {}).get("wifis") or {}
stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
if stale:
shutil.copy2(p, p + ".kiosk-bak")
for n in stale:
del w[n]
if not w:
del d["network"]["wifis"]
# Not "optional" = the boot waits (up to ~2 min) for it to get an address.
opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
for n in opt:
w[n]["optional"] = True
if stale or opt:
if not stale:
shutil.copy2(p, p + ".kiosk-bak")
mode = os.stat(p).st_mode & 0o777
with open(p, "w") as fh:
yaml.safe_dump(d, fh, default_flow_style=False)
os.chmod(p, mode)
print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
except Exception:
pass
PYEOF
done
}
do_connect() {
need_iface
local input ssid password
@@ -191,7 +371,9 @@ do_connect() {
unblock
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
local backup="" new
local country backup="" new
country=$(default_country)
[[ -n "$country" ]] && iw reg set "$country" 2>/dev/null
mkdir -p "$NETPLAN_DIR"
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
@@ -208,6 +390,7 @@ do_connect() {
echo " dhcp4: true"
echo " dhcp6: false"
echo " optional: true"
[[ -n "$country" ]] && echo " regulatory-domain: \"$country\""
echo " access-points:"
if [[ -n "$password" ]]; then
echo " $(yaml_quote "$ssid"):"
@@ -219,6 +402,7 @@ do_connect() {
chmod 0600 "$new"
mv -f "$new" "$WIFI_NETPLAN_FILE"
drop_stale_entries >/dev/null
local err
if ! err=$(netplan apply 2>&1); then
restore "$backup"
@@ -236,6 +420,11 @@ do_connect() {
if [[ -z "$addr" ]]; then
restore "$backup"
local why
why=$(diagnose_failure "$IFACE")
if [[ -n "$why" ]]; then
reply_error "Couldn't connect to \"$ssid\": $why."
fi
if [[ -n "$backup" ]]; then
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range. The previous WiFi setting was put back."
fi
@@ -269,5 +458,7 @@ case "${1:-}" in
status) do_status ;;
scan) do_scan ;;
connect) do_connect ;;
region-status) do_region_status ;;
region-set) do_region_set ;;
*) reply_error "unknown action: ${1:-}" ;;
esac
+217
View File
@@ -0,0 +1,217 @@
#!/bin/bash
################################################################################
# tests/addon_library.test.sh - tests for menus/addon_library.sh + lib/docker.sh
#
# Runs entirely in a scratch directory with a stub `sudo`, a stub `docker` and
# a fixture stack standing in for library/, so it needs no root, no Docker and
# no network - and cannot touch a real kiosk. (The real stack is exercised by
# library/install.sh itself.)
#
# bash tests/addon_library.test.sh
################################################################################
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
PASS=0 FAIL=0
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; }
check() { # check "description" command...
local d="$1"; shift
if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi
}
# ---- stubs --------------------------------------------------------------------
mkdir -p "$T/bin"
cat > "$T/bin/sudo" << 'EOF'
#!/bin/bash
# Stub sudo: drop "-u USER" and run the command as-is.
if [[ "$1" == "-u" ]]; then shift 2; fi
exec "$@"
EOF
cat > "$T/bin/docker" << 'EOF'
#!/bin/bash
# Stub docker: logs every call; container state comes from $STUB_STATE.
echo "$*" >> "$STUB_LOG"
case "$1" in
--version) echo "Docker version 99.0.0, build stub"; exit 0 ;;
info) exit 0 ;;
compose)
[[ "$2" == "version" ]] && { echo "Docker Compose version v9"; exit 0; }
exit 0 ;;
inspect)
st="$(cat "$STUB_STATE" 2>/dev/null || echo missing)"
[[ "$st" == "missing" ]] && exit 1
case "$*" in *Health*) echo healthy ;; *) echo "$st" ;; esac
exit 0 ;;
esac
exit 0
EOF
chmod +x "$T/bin/sudo" "$T/bin/docker"
export PATH="$T/bin:$PATH"
export STUB_LOG="$T/docker.log" STUB_STATE="$T/state"
echo missing > "$STUB_STATE"; : > "$STUB_LOG"
# ---- fixture "checkout" with a stub library stack ----------------------------
FIX="$T/checkout"
mkdir -p "$FIX/library/koha/certs"
echo "name: library" > "$FIX/library/docker-compose.yml"
echo "SHOULD-NOT-BE-COPIED" > "$FIX/library/.env"
echo "cert" > "$FIX/library/koha/certs/proxy.crt"
echo "koha-file" > "$FIX/library/koha/Dockerfile"
cat > "$FIX/library/install.sh" << 'EOF'
#!/bin/bash
# Stub of library/install.sh: records how it was called and writes a .env.
HERE="$(cd "$(dirname "$0")" && pwd)"
echo "install.sh $* LIBRARY_NAME=${LIBRARY_NAME:-} BIND_ADDR=${BIND_ADDR:-}" >> "$STUB_LOG"
[[ "${1:-}" == "--update" ]] && exit 0
cat > "$HERE/.env" << ENV
LIBRARY_NAME="${LIBRARY_NAME:-}"
OPAC_PORT=8080
STAFF_PORT=8081
QUICKADD_PORT=8082
ADMIN_USER=admin
ADMIN_PASSWORD=s3cretPASS
VIEWER_USER=guest
VIEWER_PASSWORD=guestPASS
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
ENV
echo running > "$STUB_STATE"
EOF
# ---- environment the addon expects -------------------------------------------
export KIOSK_USER="$(id -un)"
export KIOSK_HOME="$T/home" KIOSK_DIR="$T/home/kiosk-app" CONFIG_PATH="$T/home/kiosk-app/config.json"
export LIBRARY_DIR="$T/opt-library"
SCRIPT_DIR="$FIX"
mkdir -p "$KIOSK_DIR"
cat > "$CONFIG_PATH" << 'EOF'
{"tabs":[{"url":"https://example.com","duration":60,"username":"","password":"","name":"Example"}],
"homeTabIndex":-1,"inactivityTimeout":120,"swipeMode":"dual","allowNavigation":"same-origin","somethingUntracked":"keep-me"}
EOF
# shellcheck disable=SC1090
source "$REPO/lib/menu.sh"
source "$REPO/lib/config.sh"
source "$REPO/lib/docker.sh"
source "$REPO/menus/addon_library.sh"
export KIOSK_UNATTENDED=0
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
# ============================================================================
echo "== lib/docker.sh"
check "docker_is_installed sees the stub" docker_is_installed
check "container state: missing" test "$(docker_container_state library-koha)" = missing
echo running > "$STUB_STATE"
check "container state: running" test "$(docker_container_state library-koha)" = running
check "container health: healthy" test "$(docker_container_health library-koha)" = healthy
echo missing > "$STUB_STATE"
check "docker_free_port returns a number" test -n "$(docker_free_port 20000)"
# ============================================================================
echo "== sync files"
mkdir -p "$LIBRARY_DIR"; echo "DEPLOYED-ENV" > "$LIBRARY_DIR/.env"
library_sync_files
check "copies the stack files" test -f "$LIBRARY_DIR/koha/Dockerfile"
check "never overwrites a deployed .env" test "$(cat "$LIBRARY_DIR/.env")" = DEPLOYED-ENV
check "does not copy certificates" test ! -e "$LIBRARY_DIR/koha/certs/proxy.crt"
rm -rf "$LIBRARY_DIR"
# ============================================================================
echo "== not installed"
if library_is_installed; then bad "library_is_installed false before install"; else ok "library_is_installed false before install"; fi
addon_library_menu_builder
check "offers only Install" test "${MENU_LABELS[*]}" = "Install Library Catalog"
check "status says not installed" test "$(addon_library_status)" = "Library Catalog: not installed"
# ============================================================================
echo "== install action (kiosk-only network, show on kiosk)"
# answers: library name, allow-network=n, show-on-kiosk=y, <Enter> for pause
printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.out" 2>&1
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 "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 "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
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
addon_library_status > "$T/status.out"
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
# ============================================================================
echo "== menu while installed"
addon_library_menu_builder
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
echo "== tab toggling is idempotent"
library_add_tab >/dev/null 2>&1
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
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 keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
addon_library_menu_builder
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
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
# ============================================================================
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"
: > "$STUB_LOG"
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
check ".env BIND_ADDR is now 0.0.0.0" test "$(library_env_get BIND_ADDR)" = 0.0.0.0
check "containers were recreated" grep -q 'compose --project-directory .* up -d' "$STUB_LOG"
printf 'n\n\n' | action_library_network >/dev/null 2>&1 # back to kiosk-only
check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR)" = 127.0.0.1
# ============================================================================
echo "== access screen"
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 read-only login to share" grep -q 'guest / guestPASS' "$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"
: > "$STUB_LOG"
printf 'y\n\n' | action_library_update >/dev/null 2>&1
check "runs install.sh --update" grep -q 'install.sh --update' "$STUB_LOG"
check "update keeps the .env" test "$(library_env_get ADMIN_PASSWORD)" = s3cretPASS
# ============================================================================
echo "== uninstall keeps the books by default"
: > "$STUB_LOG"
printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down without -v" grep -qE 'compose .* down$' "$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 "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
echo "== purge needs the typed confirmation"
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
check "wrong confirmation cancels" test -f "$LIBRARY_DIR/.env"
: > "$STUB_LOG"
library_add_tab >/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 "install dir removed" test ! -e "$LIBRARY_DIR"
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
echo
echo "passed: $PASS failed: $FAIL"
[[ "$FAIL" -eq 0 ]]
+2 -2
View File
@@ -9323,7 +9323,7 @@ addon_netbird() {
echo "Get setup key from Netbird dashboard"
read -r -p "Enter setup key: " setup_key
if [[ -n "$setup_key" ]]; then
sudo netbird up --setup-key "$setup_key"
sudo netbird up --setup-key "$setup_key" --allow-server-ssh
log_success "Netbird connected"
fi
pause
@@ -9354,7 +9354,7 @@ addon_netbird() {
if [[ "$do_connect" =~ ^[Yy]$ ]]; then
echo "Get setup key from Netbird dashboard"
read -r -p "Enter setup key: " setup_key
[[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key"
[[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key" --allow-server-ssh
fi
fi
pause
+58
View File
@@ -643,6 +643,63 @@ document.getElementById('run-upgrade').addEventListener('click', async (e) => {
}
});
/* ---------------------------------------------------------------------- */
/* Region */
/* ---------------------------------------------------------------------- */
function fillSelect(sel, items, current) {
sel.textContent = '';
const list = items.slice();
if (current && !list.includes(current)) list.unshift(current);
for (const v of list) {
const o = document.createElement('option');
o.value = v;
o.textContent = v;
o.selected = v === current;
sel.appendChild(o);
}
}
async function loadRegion() {
try {
const res = await fetch('/api/region');
const r = await res.json();
if (!res.ok) throw new Error(r.error || 'Could not read the region');
document.getElementById('region-country').value = r.country || '';
fillSelect(document.getElementById('region-locale'), r.locales || [], r.locale);
fillSelect(document.getElementById('region-keyboard'), r.keyboards || [], r.keyboard);
} catch (e) {
showMessage(e.message, true);
}
}
document.getElementById('region-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
try {
const res = await fetch('/api/region', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
country: document.getElementById('region-country').value.trim(),
locale: document.getElementById('region-locale').value,
keyboard: document.getElementById('region-keyboard').value,
}),
});
const r = await res.json();
if (!res.ok) throw new Error(r.error || 'Could not save');
showMessage(r.restart ? 'Region saved - restart for the locale and keyboard to apply' : 'Region saved');
loadRegion();
} catch (e) {
showMessage(e.message, true);
}
});
document.getElementById('region-from-locale').addEventListener('click', () => {
const m = /_([A-Z]{2})/.exec(document.getElementById('region-locale').value);
if (m) document.getElementById('region-country').value = m[1];
else showMessage("That locale doesn't name a country", true);
});
/* ---------------------------------------------------------------------- */
/* Init */
/* ---------------------------------------------------------------------- */
@@ -657,6 +714,7 @@ async function init() {
}
await loadAddonStatus();
renderAddons();
loadRegion();
}
init();
+33
View File
@@ -36,6 +36,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
Addons
</button>
<button class="nav-item" data-page="region">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
Region
</button>
<button class="nav-item" data-page="update">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
Update
@@ -192,6 +196,35 @@
<div id="addons-list"></div>
</section>
<!-- Region -->
<section class="page" id="page-region">
<div class="page-header">
<h1>Region</h1>
<p>WiFi country, system language and keyboard layout. The WiFi country decides which channels the adapter may join - without it many 5 GHz networks show up in scans but won't connect.</p>
</div>
<div class="card">
<form id="region-form">
<label>
WiFi country (2 letters, e.g. US)
<input type="text" id="region-country" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
</label>
<label>
Language / locale
<select id="region-locale"></select>
</label>
<label>
Keyboard layout
<select id="region-keyboard"></select>
</label>
<div class="button-row">
<button type="submit">Save region</button>
<button type="button" id="region-from-locale" class="secondary">Country from locale</button>
</div>
<p class="hint">Locale and keyboard apply after a restart; the WiFi country applies immediately.</p>
</form>
</div>
</section>
<!-- Update -->
<section class="page" id="page-update">
<div class="page-header">
+35
View File
@@ -24,6 +24,7 @@ const { ACTIONS } = require('./lib/actions');
const { startJob, getJob } = require('./lib/jobs');
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
const app = express();
@@ -253,6 +254,40 @@ app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
req.on('close', () => job.listeners.delete(listener));
});
/* ---------------------------------------------------------------------- */
/* Region: WiFi country, locale, keyboard (root work in kiosk-wifi-helper) */
/* ---------------------------------------------------------------------- */
function runRegionHelper(action, input, res) {
const cmd = SUDO_CMD || WIFI_HELPER_PATH;
const args = SUDO_CMD ? ['-n', WIFI_HELPER_PATH, action] : [action];
const child = execFile(cmd, args, { timeout: 30_000 }, (err, stdout, stderr) => {
let body = null;
try { body = JSON.parse(stdout.trim().split('\n').pop()); } catch (e) { /* not our JSON */ }
if (!body) {
console.error(`${action} failed:`, stderr || (err && err.message));
return res.status(500).json({ error: 'The region helper is not available - run Advanced -> Upgrade from the admin menu' });
}
res.status(body.ok ? 200 : 400).json(body);
});
child.stdin.on('error', () => {});
child.stdin.end(input ? JSON.stringify(input) : '');
}
app.get('/api/region', (req, res) => runRegionHelper('region-status', null, res));
app.put('/api/region', (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const input = {};
for (const k of ['country', 'locale', 'keyboard']) {
if (body[k] === undefined || body[k] === '') continue;
if (typeof body[k] !== 'string' || body[k].length > 64) return badRequest(res, `${k} must be a short string`);
input[k] = body[k];
}
if (!Object.keys(input).length) return badRequest(res, 'Nothing to change');
runRegionHelper('region-set', input, res);
});
const PORT = process.env.PORT || 8090;
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
+28
View File
@@ -13,6 +13,9 @@ const assert = require('assert');
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helper.sh');
const app = require('../server');
let failures = 0;
@@ -240,6 +243,31 @@ async function main() {
assert.strictEqual(body.lockoutTimeout, 20);
});
await check('GET /api/region returns the helper\'s status', async () => {
const res = await fetch(`${base}/api/region`);
assert.strictEqual(res.status, 200);
assert.strictEqual((await res.json()).country, 'US');
});
await check('PUT /api/region passes only the given fields to the helper', async () => {
const res = await fetch(`${base}/api/region`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country: 'DE', locale: '', bogus: 'x' }),
});
assert.strictEqual(res.status, 200);
assert.deepStrictEqual(JSON.parse((await res.json()).echo), { country: 'DE' });
});
await check('PUT /api/region with nothing to change is a 400', async () => {
const res = await fetch(`${base}/api/region`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
assert.strictEqual(res.status, 400);
});
server.close();
fs.rmSync(scratchDir, { recursive: true, force: true });
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Stand-in for kiosk-wifi-helper in api.test.js: echoes the action and stdin.
input=$(cat)
case "$1" in
region-status) echo '{"ok":true,"country":"US","locale":"en_US.UTF-8","keyboard":"us","locales":["en_US.UTF-8"],"keyboards":["us"]}' ;;
region-set) jq -cn --arg i "$input" '{ok:true,echo:$i,restart:true}' ;;
*) echo '{"ok":false,"error":"unknown"}' ;;
esac