diff --git a/README.md b/README.md index 94445e5..c49f376 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,25 @@ different and only one will suit how you hang them. type and the colour option. It is not itself a saved setting — the three fields it fills are what get stored, which is why it can appear to "revert" when the same dimensions describe two rolls. +**Ask the printer what it has loaded.** This is the first thing to run when a job is refused: + +```bash +docker compose exec visitor-signin node scripts/printer-status.mjs +``` + +It reports the media width, whether the roll is continuous or die-cut, any error the printer is +holding, and the roll id that matches. `brother_ql`'s network backend only writes to the socket +and never reads, which is why a refused job still looks like a success — this asks directly. + +If the printer will not answer, work through the possibilities one at a time: + +```bash +docker compose exec -it visitor-signin node scripts/print-probe.mjs +``` + +It sends one label per roll id and waits for you to say whether anything came out, then moves on. +The `-it` matters: it asks questions and needs a terminal. + **Diagnostics from the command line.** When the console is not enough: ```bash diff --git a/package.json b/package.json index 0e2576e..3af8cd7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "dev": "node --watch src/server.js", "version": "node scripts/version.mjs", "gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", - "print-test": "node scripts/print-test.mjs" + "print-test": "node scripts/print-test.mjs", + "printer-status": "node scripts/printer-status.mjs", + "print-probe": "node scripts/print-probe.mjs" }, "engines": { "node": ">=20" diff --git a/public/css/admin.css b/public/css/admin.css index 80f45b9..13fadfe 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -415,3 +415,11 @@ pre.raw { max-height: 320px; overflow: auto; } + +/* The recommended download should not look identical to the two fallbacks. */ +.sys-actions .primary-link { + border-color: var(--deep); + background: var(--deep); + color: #fff; + font-weight: 600; +} diff --git a/public/js/admin.js b/public/js/admin.js index 92bd151..a3f9395 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1434,8 +1434,9 @@ function renderTls(tls) { itself before it lapses, and devices that trust the authority keep working without being touched again.
`; diff --git a/push-to-gitea.ps1 b/push-to-gitea.ps1 index c11d9a7..62b45c0 100644 --- a/push-to-gitea.ps1 +++ b/push-to-gitea.ps1 @@ -7,15 +7,10 @@ $Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git' -# git reports failure through its exit code, not as a PowerShell error, so every -# call has to be checked. Without this the script reports success after a -# rejected push, which is exactly what it used to do. -function Invoke-Git { - param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) - & git @Arguments - return $LASTEXITCODE -} - +# git is called directly and $LASTEXITCODE checked straight afterwards. Wrapping +# it in a function does not work: a PowerShell function returns everything written +# to the output stream, so the caller receives git's console output as well as the +# exit code, and comparing that array against 0 reports failure every time. function Fail($message) { Write-Host '' Write-Host $message -ForegroundColor Red @@ -27,86 +22,89 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Fail 'Git is not installed or not on PATH. Get it from https://git-scm.com/download/win' } -# Keeps line endings sane between Windows and the Ubuntu docker host. git config --global core.autocrlf input | Out-Null +# git refuses to commit without an identity, and says so in a way that is easy to +# miss among its other output. +$who = git config user.email +if (-not $who) { $who = git config --global user.email } +if (-not $who) { + Write-Host 'Git does not know who you are. Set that once:' -ForegroundColor Yellow + Write-Host ' git config --global user.email "you@example.com"' + Write-Host ' git config --global user.name "Your Name"' + Fail 'Nothing was committed.' +} + if (-not (Test-Path '.git')) { Write-Host 'Setting up a new local repository...' - if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' } + git init -b main + if ($LASTEXITCODE -ne 0) { Fail 'git init failed.' } } -if ((git remote) -match '^origin$') { - git remote set-url origin $Remote -} else { - git remote add origin $Remote -} +if ((git remote) -match '^origin$') { git remote set-url origin $Remote } +else { git remote add origin $Remote } -# ------------------------------------------------- finish what was started +# Whatever branch is checked out, not a hard-coded one. Pushing 'main' while the +# work sits on 'deploy' reports "Everything up-to-date" and sends nothing. +$branch = (git rev-parse --abbrev-ref HEAD).Trim() +if (-not $branch -or $branch -eq 'HEAD') { Fail 'No branch is checked out here.' } +Write-Host "Branch: $branch" -ForegroundColor Cyan -# A rebase or merge left half-done blocks everything that follows, and the error -# git gives is easy to mistake for a push problem. Catch it here and say plainly -# what to do. -$gitDir = (git rev-parse --git-dir 2>$null) +# ------------------------------------------- an unfinished rebase blocks everything + +$gitDir = git rev-parse --git-dir 2>$null if ($gitDir) { $stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') | Where-Object { Test-Path (Join-Path $gitDir $_) } if ($stuck) { Write-Host '' - Write-Host 'There is an unfinished rebase or merge in this folder.' -ForegroundColor Red - Write-Host 'Nothing else can happen until it is settled. Your options:' -ForegroundColor Yellow - Write-Host '' - Write-Host ' git rebase --abort throw the attempt away and go back to how things were' - Write-Host ' git status see which files still need attention' - Write-Host ' git rebase --continue after fixing the files git listed' - Write-Host '' - Write-Host 'If you are unsure, "git rebase --abort" is the safe one. It puts the' -ForegroundColor Yellow - Write-Host 'folder back exactly as it was before the rebase started.' -ForegroundColor Yellow - exit 1 + Write-Host 'There is an unfinished rebase or merge here.' -ForegroundColor Red + Write-Host ' git rebase --abort throw it away, back to how things were' + Write-Host ' git status see which files need attention' + Write-Host ' git rebase --continue after fixing those files' + Fail 'Nothing was done.' } } # ---------------------------------------------------------------- commit git add -A -$pending = git status --porcelain -if ($pending) { +if (git status --porcelain) { $message = Read-Host 'Describe this change (press enter for a dated default)' if (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" } - if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' } + git commit -m $message + if ($LASTEXITCODE -ne 0) { Fail 'git commit failed. The message above says why.' } Write-Host 'Committed.' -ForegroundColor Green } else { - Write-Host 'No file changes to commit. Checking for anything unpushed...' -ForegroundColor Yellow + Write-Host 'Nothing new to commit.' -ForegroundColor Yellow } # ------------------------------------------------------- catch up, then push -Write-Host 'Checking what is on the server...' -if ((Invoke-Git fetch origin) -ne 0) { - Fail 'Could not reach Gitea. Check the network and your sign in details.' -} +git fetch origin +if ($LASTEXITCODE -ne 0) { Fail 'Could not reach Gitea. Check the network and your sign in details.' } -if (git ls-remote --heads origin main) { - $behind = (git rev-list --count HEAD..origin/main 2>$null) +if (git ls-remote --heads origin $branch) { + $behind = git rev-list --count "HEAD..origin/$branch" 2>$null if ($behind -and [int]$behind -gt 0) { Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..." - if ((Invoke-Git pull --rebase origin main) -ne 0) { + git pull --rebase origin $branch + if ($LASTEXITCODE -ne 0) { Write-Host '' Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red - Write-Host '' - Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow - Write-Host ' git log --oneline HEAD..origin/main' - Write-Host '' - Write-Host 'If that is nothing you need, and this folder is the good copy:' -ForegroundColor Yellow - Write-Host ' git push --force-with-lease origin main' - exit 1 + Write-Host " git log --oneline HEAD..origin/$branch what is on the server" + Write-Host " git push --force-with-lease origin $branch if this folder is the good copy" + Fail 'Nothing was sent.' } } +} else { + Write-Host "Branch '$branch' is not on the server yet; it will be created." } -if ((Invoke-Git push -u origin main) -ne 0) { - Fail 'The push was rejected. Read the message above. Nothing was sent.' -} +git push -u origin $branch +if ($LASTEXITCODE -ne 0) { Fail 'The push was rejected. The message above says why. Nothing was sent.' } Write-Host '' -Write-Host 'Pushed successfully.' -ForegroundColor Green +Write-Host "Pushed $branch successfully." -ForegroundColor Green +git log --oneline -1 Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' diff --git a/push-to-gitea.sh b/push-to-gitea.sh index 2ef8e64..8680905 100644 --- a/push-to-gitea.sh +++ b/push-to-gitea.sh @@ -11,6 +11,15 @@ command -v git >/dev/null || fail "Git is not installed." git config --global core.autocrlf input >/dev/null 2>&1 || true +if [ -z "$(git config user.email || git config --global user.email)" ]; then + cat >&2 <<'MSG' +Git does not know who you are. Set that once: + git config --global user.email "you@example.com" + git config --global user.name "Your Name" +MSG + exit 1 +fi + [ -d .git ] || git init -b main || fail "git init failed." if git remote | grep -qx origin; then @@ -19,6 +28,11 @@ else git remote add origin "$REMOTE" fi +# Whatever branch is checked out, not a hard-coded one. +BRANCH=$(git rev-parse --abbrev-ref HEAD) +[ -n "$BRANCH" ] && [ "$BRANCH" != "HEAD" ] || fail "No branch is checked out here." +echo "Branch: $BRANCH" + # An unfinished rebase or merge blocks everything below, and git's own error is # easy to mistake for a push problem. GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git) @@ -51,28 +65,29 @@ fi git fetch origin || fail "Could not reach Gitea." -if git ls-remote --heads origin main | grep -q main; then - BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0) +if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + BEHIND=$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0) if [ "$BEHIND" -gt 0 ]; then echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..." - if ! git pull --rebase origin main; then + if ! git pull --rebase origin "$BRANCH"; then cat >&2 <<'MSG' The two histories could not be joined automatically. See what is on the server that you do not have: - git log --oneline HEAD..origin/main + git log --oneline HEAD..origin/$BRANCH If that is nothing you need, and this folder is the good copy: - git push --force-with-lease origin main + git push --force-with-lease origin $BRANCH MSG exit 1 fi fi fi -git push -u origin main || fail "The push was rejected. Read the message above. Nothing was sent." +git push -u origin "$BRANCH" || fail "The push was rejected. Read the message above. Nothing was sent." echo -echo "Pushed successfully." +echo "Pushed $BRANCH successfully." +git log --oneline -1 echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin" diff --git a/scripts/print-probe.mjs b/scripts/print-probe.mjs new file mode 100644 index 0000000..fdca621 --- /dev/null +++ b/scripts/print-probe.mjs @@ -0,0 +1,100 @@ +/** + * Tries roll ids one at a time, waiting for you to say what came out. + * + * docker compose exec -it visitor-signin node scripts/print-probe.mjs + * + * Note the -it: this asks questions, so the container needs a terminal attached. + * + * Start with printer-status.mjs — if the printer answers, it tells you the right + * id outright and this is unnecessary. Use this when the printer will not report + * its status, or when it does and the job is still refused. + */ +import fs from 'node:fs'; +import readline from 'node:readline/promises'; +import { execFileSync } from 'node:child_process'; +import db from '../src/db.js'; +import config from '../src/config.js'; +import * as printer from '../src/printer.js'; + +// Ordered by how likely each is on a 62 mm machine, cheapest guesses first. +const CANDIDATES = [ + ['62', '62 mm continuous, black only'], + ['62x100', '62 x 100 mm die-cut'], + ['62red', '62 mm continuous, black and red (DK-22251)'], + ['62x29', '62 x 29 mm die-cut'], + ['29', '29 mm continuous'], + ['29x90', '29 x 90 mm die-cut'], + ['38', '38 mm continuous'], + ['50', '50 mm continuous'], + ['54', '54 mm continuous'], +]; + +const site = db + .prepare('SELECT * FROM sites WHERE printer_host IS NOT NULL ORDER BY id LIMIT 1') + .get(); + +if (!site) { + console.error('No site has a printer address set.'); + process.exit(1); +} + +const target = `tcp://${site.printer_host}:${site.printer_port || 9100}`; +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +console.log(`\n Printer: ${site.printer_model || 'QL-820NWB'} at ${target}`); +console.log(' One label will be sent per attempt. After each, say whether anything came out.'); +console.log(' Press Ctrl+C at any point to stop.\n'); + +const results = []; + +for (const [label, description] of CANDIDATES) { + const answer = (await rl.question(` Try "${label}" (${description})? [Y/n/q] `)).trim().toLowerCase(); + if (answer === 'q') break; + if (answer === 'n') { + results.push([label, 'skipped']); + continue; + } + + const png = await printer.renderBadgePng(printer.sampleVisit(site), { ...site, printer_label: label }); + const file = '/tmp/probe.png'; + fs.writeFileSync(file, png); + + let sent = true; + let detail = ''; + try { + execFileSync( + config.printing.command, + [ + '--backend', 'network', + '--model', site.printer_model || 'QL-820NWB', + '--printer', target, + 'print', '--label', label, file, + ], + { stdio: ['ignore', 'pipe', 'pipe'], timeout: config.printing.timeoutMs } + ); + } catch (err) { + sent = false; + detail = `${err.stdout || ''}${err.stderr || ''}`.trim().split('\n').pop() || err.message; + } + + if (!sent) { + console.log(` could not send: ${detail}\n`); + results.push([label, `send failed: ${detail}`]); + continue; + } + + const came = (await rl.question(' Did a label print? [y/N] ')).trim().toLowerCase(); + if (came === 'y') { + results.push([label, 'PRINTED']); + console.log(`\n That is the one. Set "Roll loaded in the printer" so it sends ${label}.\n`); + break; + } + results.push([label, 'nothing came out']); + console.log(' Clear the error on the printer (open and close the cover) before the next try.\n'); +} + +rl.close(); + +console.log(' Summary'); +for (const [label, outcome] of results) console.log(` ${label.padEnd(8)} ${outcome}`); +console.log(''); diff --git a/scripts/printer-status.mjs b/scripts/printer-status.mjs new file mode 100644 index 0000000..6954b8d --- /dev/null +++ b/scripts/printer-status.mjs @@ -0,0 +1,151 @@ +/** + * Asks the printer what it actually has loaded, and what it is complaining about. + * + * docker compose exec visitor-signin node scripts/printer-status.mjs + * docker compose exec visitor-signin node scripts/printer-status.mjs --host 10.0.0.5 + * + * brother_ql's network backend only writes to the socket; it never reads, which is + * why a refused job still looks like a success. The Brother raster protocol has a + * status request that returns a 32 byte block describing the media in the machine + * and any error, so we ask directly. + */ +import net from 'node:net'; +import db from '../src/db.js'; + +function arg(name, fallback = null) { + const i = process.argv.indexOf(`--${name}`); + return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') + ? process.argv[i + 1] + : fallback; +} + +const site = arg('site') + ? db.prepare('SELECT * FROM sites WHERE id = ?').get(Number(arg('site'))) + : db.prepare('SELECT * FROM sites WHERE printer_host IS NOT NULL ORDER BY id LIMIT 1').get(); + +const host = arg('host', site?.printer_host); +const port = Number(arg('port', site?.printer_port || 9100)); + +if (!host) { + console.error('No printer address. Set one in Admin -> Sites, or pass --host.'); + process.exit(1); +} + +/* ------------------------------------------------------------- decoding */ + +const MEDIA_TYPES = { + 0x00: 'no media loaded', + 0x0a: 'continuous roll', + 0x0b: 'die-cut labels', + 0x4a: 'continuous roll (cleaning)', + 0x4b: 'die-cut labels (cleaning)', +}; + +const ERRORS_1 = [ + [0x01, 'no media loaded'], + [0x02, 'end of media'], + [0x04, 'cutter jam'], + [0x08, 'weak batteries'], + [0x10, 'printer in use'], + [0x80, 'printer turned off'], +]; + +const ERRORS_2 = [ + [0x01, 'wrong media — the job does not match the roll that is loaded'], + [0x04, 'expansion buffer full'], + [0x08, 'communication error'], + [0x10, 'communication buffer full'], + [0x20, 'cover is open'], + [0x40, 'cancel key pressed'], + [0x80, 'media cannot be fed'], +]; + +function decode(buf) { + if (buf.length < 32) return { error: `Short reply (${buf.length} bytes).` }; + const mediaWidth = buf[10]; + const mediaType = buf[11]; + const mediaLength = buf[17]; + return { + mediaWidthMm: mediaWidth, + mediaLengthMm: mediaLength, + mediaType: MEDIA_TYPES[mediaType] || `unknown (0x${mediaType.toString(16)})`, + mediaTypeRaw: mediaType, + errors: [ + ...ERRORS_1.filter(([bit]) => buf[8] & bit).map(([, text]) => text), + ...ERRORS_2.filter(([bit]) => buf[9] & bit).map(([, text]) => text), + ], + raw: buf.subarray(0, 32).toString('hex').replace(/(..)/g, '$1 ').trim(), + }; +} + +/** The label id brother_ql should be given, worked out from what is loaded. */ +function suggestLabel(status) { + if (status.mediaTypeRaw === 0x00) return null; + const continuous = status.mediaTypeRaw === 0x0a || status.mediaTypeRaw === 0x4a; + if (continuous) { + return String(status.mediaWidthMm); // 62, 29, 12 ... + } + return status.mediaLengthMm + ? `${status.mediaWidthMm}x${status.mediaLengthMm}` + : `${status.mediaWidthMm} (die-cut, length unknown)`; +} + +/* --------------------------------------------------------------- asking */ + +console.log(`\n Asking ${host}:${port} what it has loaded...\n`); + +const socket = net.createConnection({ host, port, timeout: 8000 }); +const chunks = []; + +socket.on('connect', () => { + // 200 null bytes clears any half-finished job, then initialise, then ask. + socket.write(Buffer.alloc(200, 0x00)); + socket.write(Buffer.from([0x1b, 0x40])); + socket.write(Buffer.from([0x1b, 0x69, 0x53])); +}); + +socket.on('data', (d) => { + chunks.push(d); + if (Buffer.concat(chunks).length >= 32) socket.end(); +}); + +socket.on('timeout', () => { + socket.destroy(); + if (!chunks.length) { + console.error(' The printer accepted the connection but sent nothing back.'); + console.error(' Some firmware only answers when idle — make sure it is not mid-job,'); + console.error(' and that nothing else is holding port 9100 open.\n'); + process.exit(1); + } +}); + +socket.on('error', (err) => { + console.error(` Could not reach it: ${err.message}\n`); + process.exit(1); +}); + +socket.on('close', () => { + const buf = Buffer.concat(chunks); + if (!buf.length) process.exit(1); + + const status = decode(buf); + if (status.error) { + console.error(` ${status.error}\n raw: ${buf.toString('hex')}\n`); + process.exit(1); + } + + console.log(` media loaded ${status.mediaWidthMm} mm ${status.mediaType}`); + if (status.mediaLengthMm) console.log(` label length ${status.mediaLengthMm} mm`); + console.log(` errors ${status.errors.length ? status.errors.join('; ') : 'none reported'}`); + console.log(` raw status ${status.raw}`); + + const suggested = suggestLabel(status); + console.log(''); + if (!suggested) { + console.log(' No media detected. Open and close the cover to make it re-read the roll.'); + } else { + console.log(` Use this roll id: --label ${suggested}`); + console.log(` Try it with: node scripts/print-test.mjs --label ${suggested}`); + } + console.log(''); +}); diff --git a/src/routes/admin.js b/src/routes/admin.js index 052ca65..ca7efbf 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1275,6 +1275,20 @@ router.get(['/tls/ca.cer', '/tls/ca.der'], (req, res) => { } }); +/** All encodings plus instructions, as one archive browsers will actually download. */ +router.get('/tls/ca-bundle.zip', (req, res) => { + try { + const zip = tls.caBundleZip(); + if (!zip) return res.status(404).send('No certificate authority has been generated yet.'); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-certificates.zip"'); + res.setHeader('Content-Length', zip.length); + res.send(zip); + } catch (err) { + res.status(500).send(`Could not build the bundle: ${err.message}`); + } +}); + router.post('/tls/renew', requireOwner, (req, res) => { try { // A brand new CA means every kiosk device has to trust it again, so it is diff --git a/src/server.js b/src/server.js index 09c0c5f..e1e9b32 100644 --- a/src/server.js +++ b/src/server.js @@ -133,6 +133,26 @@ function startRedirectServer() { http .createServer((req, res) => { + // A zip, because browsers block bare certificate downloads. + if (req.url === '/ca.zip') { + try { + const zip = tls.caBundleZip(); + if (!zip) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('No certificate authority has been generated yet.'); + } + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="visitor-signin-certificates.zip"', + 'Content-Length': zip.length, + }); + return res.end(zip); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + return res.end(`Could not build the bundle: ${err.message}`); + } + } + // DER for Apple tooling, PEM for everything else. if (req.url === '/ca.cer' || req.url === '/ca.der') { try { diff --git a/src/tls.js b/src/tls.js index 601ed1c..e8201f3 100644 --- a/src/tls.js +++ b/src/tls.js @@ -4,6 +4,7 @@ import os from 'node:os'; import crypto from 'node:crypto'; import { execFileSync } from 'node:child_process'; import config from './config.js'; +import { createZip } from './zip.js'; /** * Certificates for an internal-only kiosk. @@ -250,6 +251,80 @@ export function caCertificateDer() { return openssl(['x509', '-in', p.caCert, '-outform', 'der']); } +/** + * Every form of the authority certificate in one archive, with instructions. + * + * Browsers increasingly refuse to download a bare .crt or .cer as a dangerous + * file type, which leaves no way to get the certificate onto a device. A zip is + * accepted, and carrying all the encodings means whichever tool is being fed — + * Jamf, Windows, Android — has the one it wants. + */ +export function caBundleZip() { + const p = paths(); + if (!fs.existsSync(p.caCert)) return null; + + const pem = fs.readFileSync(p.caCert); + const der = caCertificateDer(); + const info = describe(); + + const readme = [ + `${config.siteName} — certificate authority`, + '='.repeat(60), + '', + 'Install ONE of these on each device. They are the same certificate in', + 'different encodings; which one you need depends on the tool.', + '', + ' visitor-signin-ca.cer binary DER. Jamf Pro, Apple Configurator, iOS, macOS.', + ' visitor-signin-ca.crt PEM text. Windows, Android, Chromebook, Linux.', + ' visitor-signin-ca.pem identical to the .crt, for tools expecting .pem.', + '', + 'Fingerprint (SHA-256)', + ` ${info.ca?.fingerprint || 'unknown'}`, + '', + 'Check this matches what the device shows before trusting it.', + '', + 'Valid until', + ` ${info.ca?.validTo || 'unknown'}`, + '', + 'The server certificate currently covers', + ` ${(info.server?.names || ['unknown']).join('\n ')}`, + '', + 'Installing', + '----------', + 'Jamf Pro Devices > Configuration Profiles > New > Certificate payload.', + ' Upload the .cer, scope to the kiosk devices, save. A root', + ' certificate delivered by MDM is trusted for TLS automatically.', + '', + 'Windows Double-click the .crt > Install Certificate > Local Machine >', + ' Place all certificates in the following store > Trusted Root', + ' Certification Authorities.', + '', + 'Android Settings > Security > Encryption & credentials > Install a', + ' certificate > CA certificate, then pick the .crt. Chrome on', + ' Android will not accept a certificate for a bare IP address,', + ' so reach the kiosk by hostname.', + '', + 'Chromebook Settings > Privacy and security > Security > Manage', + ' certificates > Authorities > Import, then pick the .crt.', + '', + 'iOS by hand Open the .crt in Safari, allow the profile, install it under', + ' Settings > General > VPN & Device Management, THEN turn it on', + ' under Settings > General > About > Certificate Trust Settings.', + ' Both steps are needed when installing by hand.', + '', + 'Renewing the server certificate does not change this authority, so devices', + 'only need this done once.', + '', + ].join('\n'); + + return createZip([ + { name: 'visitor-signin-ca.cer', data: der }, + { name: 'visitor-signin-ca.crt', data: pem }, + { name: 'visitor-signin-ca.pem', data: pem }, + { name: 'README.txt', data: readme }, + ]); +} + export function caCertificate() { const p = paths(); return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null; diff --git a/src/zip.js b/src/zip.js new file mode 100644 index 0000000..11ea363 --- /dev/null +++ b/src/zip.js @@ -0,0 +1,108 @@ +import zlib from 'node:zlib'; + +/** + * A small ZIP writer, so a bundle of certificates can be offered as a single + * download. Browsers increasingly refuse .crt and .cer files as dangerous types, + * and a zip is accepted where the bare certificate is not. + * + * Only what is needed here: a handful of small files, no directories, no + * encryption, no zip64. Written directly rather than pulling in a dependency for + * sixty lines of header packing. + */ + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(buffer) { + let crc = -1; + for (const byte of buffer) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff]; + return (crc ^ -1) >>> 0; +} + +/** MS-DOS packs the date and time into two 16 bit words, with two second resolution. */ +function dosStamp(date) { + const time = + (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { time, day }; +} + +/** + * @param {Array<{name: string, data: Buffer|string}>} files + * @returns {Buffer} the complete archive + */ +export function createZip(files) { + const now = new Date(); + const { time, day } = dosStamp(now); + const locals = []; + const central = []; + let offset = 0; + + for (const file of files) { + const name = Buffer.from(file.name, 'utf8'); + const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, 'utf8'); + const compressed = zlib.deflateRawSync(raw); + // Storing uncompressed is allowed and is smaller for data that does not shrink. + const useDeflate = compressed.length < raw.length; + const data = useDeflate ? compressed : raw; + const method = useDeflate ? 8 : 0; + const crc = crc32(raw); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); // local file header signature + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(method, 8); + localHeader.writeUInt16LE(time, 10); + localHeader.writeUInt16LE(day, 12); + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(data.length, 18); + localHeader.writeUInt32LE(raw.length, 22); + localHeader.writeUInt16LE(name.length, 26); + localHeader.writeUInt16LE(0, 28); // extra field length + + locals.push(localHeader, name, data); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); // central directory signature + centralHeader.writeUInt16LE(20, 4); // version made by + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 8); + centralHeader.writeUInt16LE(method, 10); + centralHeader.writeUInt16LE(time, 12); + centralHeader.writeUInt16LE(day, 14); + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(data.length, 20); + centralHeader.writeUInt32LE(raw.length, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt16LE(0, 30); // extra + centralHeader.writeUInt16LE(0, 32); // comment + centralHeader.writeUInt16LE(0, 34); // disk number + centralHeader.writeUInt16LE(0, 36); // internal attributes + centralHeader.writeUInt32LE(0, 38); // external attributes + centralHeader.writeUInt32LE(offset, 42); // offset of local header + + central.push(centralHeader, name); + offset += localHeader.length + name.length + data.length; + } + + const centralBuffer = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); // end of central directory + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralBuffer.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...locals, centralBuffer, end]); +} diff --git a/tatus b/tatus new file mode 100644 index 0000000..1c488aa --- /dev/null +++ b/tatus @@ -0,0 +1,3 @@ +[33mb1a7933[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mdeploy[m[33m)[m Printer Debug 2 +[33meb98658[m Printing Debug +[33m8bc7179[m[33m ([m[1;31morigin/deploy[m[33m)[m Server-side printing, roll type setting, cache headers