StreetSign Admin’s guide

Here’s first a guide on how to set up StreetSign to play with. For full proper deployment (for real live production usage) check the Deploying StreetSign in Production page.

Installation

StreetSign requires python 3.10+, imagemagick (to generate thumbnails), make, and uv (used by setup.sh to create the virtualenv and install dependencies). If you are installing on a fresh server, you may need to install python3-headers or python3-dev or whatever your distribution calls it.

(On a stock OSX computer, it doesn’t need anything.)

Once you’ve cloned or otherwise downloaded StreetSign and put it where you want it to be, you need to run the setup script:

./setup.sh

which will create a python virtualenv in .venv, and install all the libraries and other requirements into there (via the Makefile).

Running it.

To run the server in ‘production mode’ you can use the built in waitress web server:

./run.py waitress

In production mode the server refuses to start if ``SECRET_KEY`` is still the insecure default — set a unique random value (see below) before deploying.

Which will spin up a version which should be totally capable for small deployments (up to a hundred screens or so, I guess). If you are going to be on a public network, then it’s advised that you run StreetSign - either with waitress or another WSGI server of your choice - behind nginx or another reverse proxy, which should keep things a bit saner. If you’re on a public network, also remember to set up your reverse proxy to use SSL, so that you aren’t sending log-in credentials around in plaintext.

Static files are served in-process by WhiteNoise, so no separate web server or reverse proxy is required for static assets.

If you want to run another WSGI server, remember the virtualenv that StreetSign is using, so any scripts you write need to use the python found in there (.venv/bin/python). You can use pip from in there to install any pypi packages you need too (.venv/bin/pip install gunicorn, say).

If you want to run the bare development server (not for production) you can run ./run.py with no argument. The interactive Werkzeug debugger is off by default; if you opt in with FLASK_DEBUG=1 the server binds only to 127.0.0.1, because the debugger exposes a remote code-execution console.

Users

When you first install, the database is seeded with three demo users for you. The password for each one matches its login name, so:

  • admin / admin — a full administrator (in the admins group)

  • editor / editor — can write and publish on all feeds (in the editors group)

  • viewer / viewer — read-only

Change these passwords as soon as possible, especially admin.

Because some things display differently for admins compared to ‘normal’ users, the pre-created editor and viewer accounts are handy for checking how the interface looks for non-admins.

Account lockout

To slow down password-guessing, an account is automatically locked out after a number of consecutive failed login attempts (MAX_FAILED_LOGINS, default 10). A locked-out account cannot log in. There is currently no unlock control in the web interface, so an administrator must clear the is_locked_out flag (and failed_logins) directly in the database:

sqlite3 database.db "UPDATE user SET is_locked_out = 0, failed_logins = 0 WHERE loginname = 'the-user';"

Password hashes and moving the database

User passwords are stored hashed with bcrypt (each with its own per-password salt). Before hashing, the password is run through SHA-256 so that passwords longer than bcrypt’s 72-byte limit are fully used. The password hashes are independent of SECRET_KEY — you can rotate SECRET_KEY without locking anyone out, and you can move a database.db file between installations without needing to copy the SECRET_KEY.

SECRET_KEY is still important: it signs Flask session cookies, so it must be set to a long random value in config.py or via the environment, and should NEVER be committed to a repository or shared outside the deployment. The server refuses to start in production if it is left at the insecure default.

Connected clients

The Connected Clients page (in the admin sidebar) shows every display client that has checked in recently: which screen or client alias it is showing, its IP address, and when it was last seen. A screen is considered connected while it has sent a heartbeat within SCREEN_CLIENT_TTL seconds (default 30); screens send a heartbeat every SCREEN_HEARTBEAT_INTERVAL seconds (default 10).

Each row has a Refresh button, and the page has a Refresh All button. Refreshing does not cut the connection: it asks the client to reload its page on its next heartbeat, so it takes effect within about one heartbeat interval. This is handy after changing a screen layout or when a display has got itself stuck.

Client addresses behind a reverse proxy

When StreetSign runs behind one or more reverse proxies (nginx, nginx-proxy-manager, a Docker ingress, …), the proxies add X-Forwarded-For / X-Forwarded-Proto headers but the socket peer the app sees is the proxy. Tell StreetSign how many proxies are in front of it so it can recover the real client address and protocol:

  • TRUSTED_PROXY_HOPS — the number of chained proxies (set it in config.py or via the environment variable of the same name). 0 (the default) means “direct access, ignore forwarded headers”; 1 is a single proxy such as nginx-proxy-manager.

StreetSign applies Werkzeug’s ProxyFix with that hop count, so request.remote_addr becomes the real client and request.scheme honours X-Forwarded-Proto. This value is used for the Connected Clients page and for the per-IP login rate limiter.

Only set this to the number of proxies that actually sit in front of StreetSign: trusting forwarded headers from an untrusted client lets it spoof its address. If the app’s port is reachable directly (bypassing the proxy), keep it private or leave TRUSTED_PROXY_HOPS at 0.

Note that nginx-proxy-manager overwrites X-Forwarded-Proto (it does not append), so with more than one proxy the scheme resolves to the innermost hop’s scheme; the client IP (from X-Forwarded-For, which is appended) is still correct.

Housekeeping & removing old content

By default, StreetSign will tag content whose lifetime ended over a week ago as “archived”. This means it no longer shows up on the interface for anyone except admin users. After a month, it will be deleted, including any uploaded images.

Housekeeping is not run automatically by the display clients. An administrator triggers it manually with the “housekeeping” button on the “All Posts” page or on the front page (Dashboard). It can also be triggered by an authenticated admin HTTP POST to /posts/housekeeping.

Housekeeping is quick as long as it is run regularly. If you want to run it on a schedule, use cron or another task scheduler to POST to the endpoint as an administrator. The endpoint requires an admin session (there is currently no API-token mechanism), so a cron job must log in first and send the resulting session cookie. In practice, the admin UI button is the simplest option.

External feeds are updated manually too. Each external data source has its own page where an administrator can press “Run Now” or change its update frequency; the update endpoint skips a source until its frequency interval has elapsed. There is also an authenticated admin HTTP POST to /external_data_sources/ which runs every source that is due. Display clients do not trigger feed imports.

If you are on a public network and worry about DOS issues, then realistically you should be running behind a reverse proxy such as nginx. With nginx you can add restrictions on what URLs are accessible by any IP address, so you can limit these addresses to only be accessed by the machine with cron, for instance.

Server Time

You may well have your main server running in one timezone (say GMT), but actually be using the signs in another time zone. By default, clients will all use their own local time zone, and the server uses the server time. There is a configuration option you can set in config.py:

TIME_OFFSET=60

for example, which will offset post lifetimes, etc, by an hour. (Minutes are used so that half-hour-off timezones are supported).

Configuration Reference

All configuration options can be set in config.py (do not edit config_default.py). See config_default.py for the full list of defaults. Key options:

  • SECRET_KEY — Flask session signing key. Must be set to a unique random value in production; the server refuses to start if left at the insecure default. No longer used for password hashing, so it can be rotated freely.

  • DATABASE_FILE — path to the SQLite database (default: database.db)

  • TIME_OFFSET — timezone offset in minutes (default: 0)

  • MODE — 'production' or 'development' (default: 'production')

  • CSRF_ENABLED — enable CSRF protection for all state-changing requests, including login and logout (default: True)

  • MAX_FAILED_LOGINS — consecutive failed logins before an account is locked out (default: 10)

  • MAX_CONTENT_LENGTH — max upload size in bytes (default: 1 GB)

  • SCREEN_HEARTBEAT_INTERVAL — seconds between display-client heartbeats (default: 10)

  • SCREEN_CLIENT_TTL — seconds before a silent display client is considered offline on the Connected Clients page (default: 30)

  • TRUSTED_PROXY_HOPS — number of reverse proxies in front of StreetSign whose X-Forwarded-For / X-Forwarded-Proto headers are trusted (default: 0 = not behind a proxy). Can also be set via the TRUSTED_PROXY_HOPS environment variable.

  • LOG_LEVEL — logging level for the application (default: INFO). One of DEBUG, INFO, WARNING, ERROR. Can also be set via the LOG_LEVEL environment variable.

  • SITE_VARS — dict with site_title, site_dir, user_dir, user_url for paths and branding

Logging

StreetSign uses Python’s standard logging module. When started via ./run.py (or ./run.py waitress), logging.basicConfig() is called with a timestamped format at the level given by LOG_LEVEL. All application messages go through streetsign_server loggers and appear on stderr — in Docker they’re captured by docker logs, under systemd by journalctl -u streetsign.service.

Backup & Restore

A naïve cp database.db can produce a corrupt copy because SQLite in WAL mode also writes to database.db-wal. Use the built-in backup script, which uses the SQLite online backup API to produce a consistent snapshot while the server keeps running:

make backup
# or explicitly:
.venv/bin/python scripts/backup_db.py /path/to/backup.db

In Docker, mount a backup volume and run it from cron:

docker exec streetsign python scripts/backup_db.py /backups/streetsign-$(date +%F).db

To restore, stop the server, replace database.db with the backup file, and restart.

Health Check

A lightweight /health endpoint is available for Docker HEALTHCHECK, load balancers, and monitoring systems. It runs a SELECT 1 against the database and returns JSON:

{"status": "ok", "database": "ok"}        # 200 OK
{"status": "error", "database": "unreachable"}  # 503

No authentication is required.