LITHOS / getting started / certificates

Certificates

You do not need to read this page. The one-time notice on staff pages is harmless, and most rooms never look further. It is here for the Sunday the notice stops being harmless — a new phone, a changed address, an iPhone that will not tap through — and for anyone who wants a clean padlock on every device.

The shape of it

Why there's a notice at all

Lithos serves two addresses on your WiFi:

  • A plain http address for listeners. Reading captions and hearing audio needs nothing special, so the congregation joins with no warning of any kind. That is deliberate.
  • An https address for anything that uses a microphone — the Presenter page, the Speaker page, the operator page, room speakers, and private groups. Browsers only hand out the microphone on a secure page, full stop.

Secure means a certificate, and a certificate has to be issued for a name by someone every phone already trusts. A box on church WiFi has neither: its address is 192.168.1.50, and no public authority will issue for a private address. So Lithos makes its own certificate, and each device that meets it asks you once whether to trust it. That question is the notice.

Tapping through it on your own server is safe: you know what is on the other end. It only matters that the address you typed is really your box.

What you get out of the box

The built-in certificate

On first run the app issues a certificate for localhost and the machine's current LAN address (plus any name you give the server with LT_PUBLIC_HOST), and stores it beside its other data. Each phone or laptop accepts it once and remembers. Two things make a new certificate, which every device then has to accept again — worth knowing before a service rather than during one:

  • The machine's LAN address changed. The address is written into the certificate. Set the laptop up at home, carry it to church, and it gets a different address — so at startup the app notices and re-issues, carrying the old address along too. A laptop that moves between two buildings keeps working in both; every phone just sees the notice one more time.
  • About once a year. Apple devices reject certificates valid for more than 825 days and give you no way to tap through — Safari's "visit this website" link simply does nothing. Lithos therefore issues for 397 days and rolls over 30 days before expiry, so the re-accept lands on an ordinary weekday, not mid-service.

Where to tap

  • Android / Chrome / Edge: Advanced → Proceed to 192.168.… (unsafe).
  • iPhone / Safari: Show Details → visit this website → Visit Website.
  • Firefox: Advanced → Accept the Risk and Continue.

One subtlety explains most "it loaded but nothing works" reports: the notice is shown for the page, but a page's live connection — the WebSocket that actually carries audio — gets no notice, because browsers have nowhere to show one. So the presenter page can load and the microphone silently never connect. After you accept, reload once; the connection then goes through. Current builds detect this and tell you to reload rather than leaving you guessing.

Give the box a fixed address

The single most useful thing you can do, whichever option below you pick or none: give the translation machine a DHCP reservation on the router (or a static IP). A lease that changes re-issues the certificate and breaks every phone's bookmark at once.

Read this before choosing an option

Desktop app vs. server

The three options below replace the built-in certificate with one devices already trust. They apply to the server — the Docker or Node install from rung 2. The desktop app has no setting for bringing your own certificate: it always uses the built-in one, stored in its data folder (%APPDATA%\Lithos Translate\tls on Windows, ~/Library/Application Support/Lithos Translate/tls on a Mac).

That is an honest limitation, not a hidden switch. If a clean padlock on every device matters to your room, the path is the always-on server, which is where a certificate makes sense to manage anyway. On the desktop app the notice is once per device per year, and phones that only listen never see it.

Everything from here on assumes you start the server yourself and can set environment variables — the same place your API keys and LT_OPERATOR_PIN go.

Option A · a building you control

mkcert on the LAN

mkcert makes a tiny certificate authority of your own, installs it on the machine, and issues certificates that machine trusts. Install the same authority on the staff phones and laptops, and they see a padlock with no notice — for this server, and for any certificate you ever issue with it.

  1. On the server machine, once:
    mkcert -install                         # create and trust the local CA
    mkcert 192.168.1.50 lithos.local localhost   # issue for the LAN address (and any names you use)
    That writes two files in the current folder, something like 192.168.1.50+2.pem and 192.168.1.50+2-key.pem.
  2. Point the server at them and restart:
    LT_TLS_CERT=/certs/192.168.1.50+2.pem \
    LT_TLS_KEY=/certs/192.168.1.50+2-key.pem \
    GEMINI_API_KEY=… LT_OPERATOR_PIN=… npm run server
    Under Docker, mount the folder and pass the same two variables with -e. If the server cannot read them it says so at startup (LT_TLS_CERT/KEY unreadable — falling back to self-signed) and carries on with the built-in one.
  3. Install the authority on staff devices. mkcert -CAROOT prints the folder; the file to copy is rootCA.pem (never the -key file). AirDrop or email it to the phone and install it — on iPhone: open the profile, then Settings → General → About → Certificate Trust Settings and switch it on; on Android: Settings → Security → Install a certificate → CA certificate.

Devices with the authority see no notice anywhere. Devices without it still work exactly as before — one tap.

A caution. A root authority installed on a phone is a powerful thing: whoever holds its key can issue a certificate for any site that phone will believe. Install it only on devices you own, keep the rootCA-key.pem file on the server machine and nowhere else, and prefer Option B for phones that are not yours.

Option B · no notice on any device, ever

A real certificate for a private address

Public authorities will not issue for an IP like 192.168.1.50. They will issue for a name — and the way they check you own the name (the DNS-01 challenge) is by asking you to publish a record in your domain's DNS. The authority never connects to your server. So a public name can point at a private address, and the certificate is still real:

church.example.org.   A   192.168.1.50     # public DNS, private target

Phones on the church WiFi resolve the name to the LAN address and validate the certificate against the name, like any other site. Off the WiFi the name resolves to an address that goes nowhere — which is exactly what you want.

  1. Have a domain whose DNS provider has an API (most do — Porkbun, Cloudflare, Namecheap, Route 53…). Add the A record above, pointing at the server's reserved LAN address.
  2. Issue with acme.sh (any ACME client with your provider's DNS plugin works; this is the common one):
    curl https://get.acme.sh | sh -s email=you@example.org
    export PORKBUN_API_KEY=…  PORKBUN_SECRET_API_KEY=…    # or your provider's variables
    ~/.acme.sh/acme.sh --issue --dns dns_porkbun -d church.example.org
    ~/.acme.sh/acme.sh --install-cert -d church.example.org \
      --fullchain-file ~/lithos-certs/fullchain.pem \
      --key-file       ~/lithos-certs/privkey.pem \
      --reloadcmd      'systemctl restart lithos'     # whatever restarts your server
    A wildcard (-d '*.example.org') costs nothing extra with DNS-01 and saves re-issuing for every future name.
  3. Point the server at it — and name the host:
    LT_TLS_CERT=/home/lithos/lithos-certs/fullchain.pem
    LT_TLS_KEY=/home/lithos/lithos-certs/privkey.pem
    LT_PUBLIC_HOST=church.example.org          # the name goes on QR codes and printed links, and is accepted — see below
    LT_TLS_PORT=8443                           # optional; default 8438 (Docker image: 8443)
    If phones will reach the box by more than one name, list the others in LT_ALLOWED_HOSTS=name1,name2.
  4. Open https://church.example.org:8443/presenter on a phone on the WiFi. Padlock, no notice. Put that address on the staff flyer.

Why naming the host is not optional

Point the server at the certificate but skip LT_PUBLIC_HOST / LT_ALLOWED_HOSTS and you get a failure that looks like a certificate problem and is not: the page loads with a clean padlock, and then every live connection is refused —

⚠ The page loaded, but the connection to /operator-ws was refused …

The reason is a guard, not a bug. Live connections are exempt from the browser's same-origin rule, so the server only accepts them from localhost, .local names and private addresses. Otherwise a web page you happened to open elsewhere could point a name of its own at your LAN address and script connections to your service. A real hostname that resolves to a private address is indistinguishable from that trick — and it is precisely what Option B gives you. Naming the host (either variable will do) tells the server this one is yours. The server reports whether it recognises a name, so a current build says which of the two problems you have instead of sending you to accept a notice that will never appear.

Three things that bite

  • Use the fullchain, not the bare leaf. iPhones reject a chain they cannot complete. If your provider ships the leaf and the intermediate separately: cat leaf.pem intermediate.pem > fullchain.pem.
  • Reserve the address. The A record points at one fixed address; a lease change breaks every phone at once and no certificate can save you.
  • --reloadcmd is not optional. These certificates last 90 days. Without it the renewal lands on disk and the running process keeps serving the expired one — discovered, invariably, on a Sunday morning.

The private key should be chmod 600 and owned by the account that runs the server. If it arrived in a browser's download folder, move it out.

Option C · you already run one

A reverse proxy in front

If Caddy, nginx or Traefik already terminates TLS on the box — or you are deploying on a cloud host with a real domain — let it do the certificate and put Lithos behind it. Tell Lithos so with:

LT_BEHIND_PROXY=1

With that set, the app believes the proxy's X-Forwarded-Proto header about whether a request arrived over https, stops redirecting staff pages to its own https port, and stops sending phones to that port for private groups — everything stays on the proxy's one clean address. The proxy has to send that header and pass WebSockets through (Caddy does both by default; in nginx that is proxy_set_header X-Forwarded-Proto $scheme; plus the usual Upgrade/Connection pair on the location). Name the public host with LT_PUBLIC_HOST here too, for the same reason as above.

This is also the way to put the main listener service behind https if you want the congregation's stream encrypted on the wire — the proxy serves everything on one clean address, and the one-tap notice is gone for listeners and staff alike.

A minimal Caddyfile, for the record:

church.example.org {
  reverse_proxy 127.0.0.1:8080
}
Fixes

When something's wrong

The page loaded, the notice is gone, but the mic / operator page never connects
First, reload once — the page's live connection gets no notice of its own and only goes through after you have accepted the page's. If it still fails and you are using a real domain name, set LT_PUBLIC_HOST (or LT_ALLOWED_HOSTS) to that name and restart. The page itself says which of the two it is.
Safari says "This Connection Is Not Private" and won't let me continue
The certificate is either older than the Apple limit or does not cover the address you typed. On the server, check what it covers:
openssl x509 -in <data>/tls/cert.pem -noout -text | grep -A1 "Subject Alternative Name"
If the address you are dialing is missing, delete the tls folder in the data directory and restart: a correct one is regenerated. (<data> is /data under Docker, LT_DATA_DIR or the working directory for Node, and the app's data folder for the desktop app — see above.)
Every phone had to accept the notice again this week
The server's LAN address changed (a new lease, a different router, the laptop came from home), so the certificate was re-issued. Give the machine a DHCP reservation and it stops. If it was not the address, check the date — a rollover happens once a year, 30 days before expiry.
It worked for months and then stopped on a Sunday
With a real certificate (Option B), this is a renewal that landed on disk without a restart. Add a --reloadcmd to the acme.sh install step and restart the server now. With the built-in certificate, the rollover makes a new one every phone accepts once more — that is expected, not a fault.
"LT_TLS_CERT/KEY unreadable — falling back to self-signed"
The server could not open one of the two files: wrong path, a Docker mount that isn't there, or a key the server's account is not allowed to read. It carried on with the built-in certificate so the service stayed up. Fix the path or permissions (chmod 600, owned by the server's user) and restart.
The mkcert padlock is clean on my laptop but not on the phones
The phone does not have the authority installed, or (iPhone) it is installed but not trusted — that is a separate switch under Settings → General → About → Certificate Trust Settings. Until then the phone falls back to the one-tap notice, which still works.
Can I skip all this and just use http for staff pages?
Only on the machine itself — http://localhost is treated as secure by every browser, so a presenter laptop that is the server never sees a notice. Any other device needs https for the microphone; there is no switch to change that, because it is the browser's rule, not ours.

Still stuck? Write to support@lithos.community with the server's startup lines (they say which certificate it loaded and which hosts it allows) and a photo of what the phone shows.