Registry / RepositoryTypeScriptRustPython
Rollpie
ReadmeFiles
Versions
Info
Download
0.1.280.1 KB2026-09-140.1.167.6 KB2026-09-140.1.064.9 KB2026-09-14
Version
0.1.2
Copyright
Rollpie, Irohabook
Publisher
math
Published
2026-09-14
Size
80.1 KB
Downloads
0
Checksum
65387e928cd11d6abf76cfb0f7a839dd3b2b7bb9cdbe78e7b058d3213d9fbcc2
Dependencies
None

mon@0.1.2

rollpie get rust/mon@0.1.2

mon

A Rust library that provides the common parts of a server on top of axum. It brings a shared error type that answers in JSON, and extractors that also fail in JSON. It also builds HTML, relays requests to another server, and keeps sessions in one of four stores.

Data is stored in SQLite through sqlx.

The Japanese documentation is in README.ja.md.

Usage

mon claims no paths. The application decides what goes on which path, and whether to place anything at all. route::wrap only applies the middleware and inserts the state; it adds no routes.

use axum::Router;
use axum::routing::get;
use mon::{AppState, conf, database, page, relay, route, server, session};
use std::sync::Arc;
use tokio::net::TcpListener;

let conf = Arc::new(conf::load()?);              // building a Conf directly also works
let database = database::connect(&conf.database_url).await?;
let store = session::store(&conf, &database);
session::prepare(&store).await?;
session::start_sweep(store.clone(), conf.session_max_age);

// the names, and whether to place each part at all, are decided here
let routes = my_routes()
    .route("/health", get(route::health))
    .nest("/api/session", session::router())
    .nest("/relay", relay::router())
    .nest_service("/static", route::assets(&conf))
    .fallback(page::not_found);

let state = AppState {
    database,
    client: relay::client(&conf)?,
    session: store,
    conf: conf.clone(),
};

// limits the time for receiving headers, and hands the TCP peer to ClientIp
let listener = TcpListener::bind((conf.host, conf.port)).await?;
server::run(listener, route::wrap(routes, state), &conf, shutdown).await?;

shutdown is a future that completes when the server should stop, such as on Ctrl+C or SIGTERM. server::run then stops accepting and returns after the requests in progress finish.

If you want /health to be a page about a clinic's services, simply put that page there. mon does not take the path first. tests/collision.rs checks this.

Names other than paths are not fixed either. The cookie name comes from SESSION_COOKIE, the session table from SESSION_TABLE, the store directory from SESSION_DIRECTORY, and the request ID header from REQUEST_ID_HEADER. Leave any of them empty and it is not used.

The application creates its own tables. The only table mon creates is session, when the sqlite store is chosen, and session::prepare creates it with create table if not exists. It does not mix into the application's migrations.

Running

cargo run                       # mon alone, on http://localhost:3000
cargo run --example todo        # an example with /api/todos and a list page
RUST_LOG=debug cargo run        # more detailed logs
PORT=8080 cargo run             # another port
cargo test                      # tests
cargo build --release           # with LTO and strip

The crate uses edition 2024, so it needs rustc 1.85 or later.

Building for Linux

In production, run the executable from a release build instead of cargo run. No separate application server such as gunicorn is needed. The executable contains the HTTP server itself (hyper, through axum::serve). It runs on the multi-threaded tokio runtime, so one process uses every CPU core.

Build in the directory of your application that depends on mon. In mon's own directory, the same commands build src/main.rs, which runs mon alone.

A build is native code for one operating system and one CPU. An executable built on macOS runs only on macOS, so build one for the server.

On a Linux machine, cargo build --release is enough, and the executable appears in target/release/.

From macOS, cross-compile with cargo-zigbuild. The bundled SQLite and the TLS library (aws-lc-sys) contain C code, so they need a C compiler for Linux, which zig provides.

brew install zig
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-unknown-linux-gnu

The executable appears in target/x86_64-unknown-linux-gnu/release/. For an ARM server, use aarch64-unknown-linux-gnu instead. Running uname -m on the server tells which one it is. If the server says a GLIBC version is not found, add the server's glibc version to the target, as in x86_64-unknown-linux-gnu.2.28. Running ldd --version on the server shows that version.

The release profile uses fat LTO and codegen-units = 1, so a build takes a lot of memory and CPU time. Avoid building on a small server that is already answering requests.

Running on a server

The server needs no Rust toolchain. SQLite is compiled into the executable, so SQLite does not need to be installed either. Copy the executable to the server, along with the static directory if route::assets serves one.

Run it under systemd, which starts it when the machine boots and starts it again whenever it stops. The following is an example, where app stands for the name of your application.

[Unit]
Description=app
After=network.target

[Service]
User=app
WorkingDirectory=/srv/app
ExecStart=/srv/app/app
Environment=PORT=3000
Environment=SESSION_SECURE=true
Environment=CLIENT_IP_HEADER=x-forwarded-for
UMask=0077
Restart=always

[Install]
WantedBy=multi-user.target
sudo cp app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now app

Always set WorkingDirectory. By default, mon.db, static and session are looked up relative to the directory the process starts in. Absolute paths can be given instead through DATABASE_URL, STATIC_DIRECTORY and SESSION_DIRECTORY. The account in User= needs write access to that directory, because mon.db and its WAL files are created there. UMask=0077 makes mon.db, its WAL files and the session files readable only by that account. Logs go to standard output, so journalctl -u app shows them.

To update, put the new executable next to the old one, rename it over the old one, and run sudo systemctl restart app. Overwriting a running executable in place fails with "Text file busy". rsync writes to a new file and then renames it, so it can be used as it is. systemctl restart sends SIGTERM first. src/main.rs passes Ctrl+C and SIGTERM to server::run as shutdown, so the requests in progress finish before the process exits.

Put a reverse proxy such as nginx in front to handle TLS. Inside the server block for your domain, pass requests to mon like this.

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

With a single nginx, CLIENT_IP_HEADER=x-forwarded-for and TRUSTED_PROXY=0 read the client IP correctly, as described under "Client IP". SESSION_SECURE=true is needed because browsers reach the site over HTTPS.

mon listens on the address in HOST, which defaults to 127.0.0.1. Requests that do not pass through the proxy therefore cannot reach it. When the proxy runs on another machine or mon runs in a container, set HOST=0.0.0.0 and let nothing but the proxy reach the port. While CLIENT_IP_HEADER is set, a request that reaches the port directly can forge X-Forwarded-For.

Start your application with server::run, not axum::serve. axum::serve sets no limit on the time for receiving headers, so a client that sends headers slowly, or connects and sends nothing, keeps the connection. server::run closes such connections after REQUEST_TIMEOUT.

Parts mon provides

The application decides where each part goes. Paths that appear below assume the arrangement in the example above.

  • route::health - returns only {"status": "ok"} and does not touch the database
  • session::router() - reads with GET, writes with POST, and discards with DELETE
  • relay::router() - relays / and everything under it
  • route::assets(&conf) - a service that serves static files
  • page::not_found - the HTML for a request that matches no route
  • server::run - listens with a limit on the time for receiving headers, and stops after the requests in progress finish

Every error is JSON of the form {"error": "..."}. Panics and timeouts take the same form. A timeout answers 503, not 408, because 408 would allow the client to send the same request again. There are three exceptions. page::not_found returns HTML. The 404 from route::assets and the 413 for a body over the limit are text/plain. Both come from tower-http as they are.

Environment variables

All of them are optional. They are read in one place, conf.rs.

An unset variable takes its default, but a variable that is set and cannot be read stops the startup. Quietly falling back to the default would start the server on a port nobody asked for, and the process would run while the proxy in front sees 502, the hardest failure to track down.

mon stops with messages like these. They are written in Japanese.

PORT=800o             -> PORT=800o を数として読めません(invalid digit found in string)
PORT=0                -> PORT=0 は 1 以上にしてください
CORS_CREDENTIALS=ture -> CORS_CREDENTIALS=ture は true か false で書いてください

Numeric settings do not accept 0, because PORT=0 lets the OS pick any free port. Only two accept 0: CORS_MAX_AGE, where it means preflights are not cached, and TRUSTED_PROXY, where it means nothing is skipped.

  • PORT - the port to listen on. Default 3000
  • HOST - the address to listen on. Default 127.0.0.1, which only the same machine can reach
  • DATABASE_URL - where the SQLite database is. Default sqlite:mon.db
  • STATIC_DIRECTORY - the directory of static files. Default static
  • STATIC_CACHE_CONTROL - the Cache-Control sent with static files. Default no-cache. Empty sends none
  • RELAY_ORIGIN - the origin the relay forwards to. Default http://localhost:6060
  • CORS_ORIGIN - allowed origins, separated by commas. Empty allows every origin
  • CORS_METHOD - allowed methods, separated by commas. Empty leaves it to the defaults
  • CORS_HEADER - allowed request headers, separated by commas. Empty leaves it to the defaults
  • CORS_EXPOSE_HEADER - response headers that JavaScript may read. Empty leaves it to the defaults
  • CORS_MAX_AGE - seconds the browser may keep a preflight result. Default 3600
  • CORS_CREDENTIALS - whether requests with cookies are allowed. Default false
  • SESSION_STORE - where sessions are kept: none memory file sqlite. Default sqlite
  • SESSION_MEMORY_LIMIT - the maximum number of sessions the memory store holds. Default 100000
  • SESSION_DIRECTORY - the directory for the file store. Default session
  • SESSION_TABLE - the table name for the sqlite store. Default session
  • SESSION_COOKIE - the cookie name. Default sid
  • SESSION_MAX_AGE - seconds a session is kept. Default 2592000 (30 days)
  • SESSION_SECURE - whether the cookie gets Secure. Default false. Turn it on in production
  • SESSION_SAME_SITE - lax strict none. Default lax
  • REQUEST_ID_HEADER - the header that carries the request ID. Default x-request-id. Empty adds none
  • CLIENT_IP_HEADER - the header to read the client IP from. Empty by default, which means the TCP peer is used
  • TRUSTED_PROXY - how many entries of that header to skip from the right. Default 0
  • CONTENT_SECURITY_POLICY - the CSP added to responses. Empty by default, which adds none
  • BODY_LIMIT - the maximum request body size in bytes. Default 2 MiB
  • REQUEST_TIMEOUT - the maximum seconds for one request, which server::run also applies to receiving headers. Default 10
  • RELAY_TIMEOUT - the maximum seconds to wait for the upstream of the relay. Default 3

CORS

With CORS_ORIGIN empty, requests from any origin pass; when it is set, only those origins pass. CorsLayer answers the preflight OPTIONS itself, so no handler is needed for it.

A value in CORS_ORIGIN must match the Origin the browser sends, character for character. Write it as scheme://host, with a port if needed, and without a path or a trailing slash. Write it in lowercase. Any other form stops the startup, because a setting that is written yet has no effect is the hardest to notice. CORS_ORIGIN=* is refused too. To allow every origin, leave it empty. * in CORS_METHOD, CORS_HEADER and CORS_EXPOSE_HEADER is refused in the same way, and so is a value that cannot be read as a method or a header name.

CORS_CREDENTIALS=true lets requests with cookies through. CORS then forbids answering * for the origin, the methods and the headers. If credentials are turned on while CORS_ORIGIN is empty, mon logs a warning and drops only the credentials. When methods and headers are not specified, mon answers with the following.

  • Methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
  • Request headers: content-type, authorization
  • Response headers readable from JavaScript: x-request-id

Without credentials, all three are *.

Client IP

Behind a proxy such as nginx, the TCP peer is the proxy itself. The real client's address arrives in X-Forwarded-For or X-Real-IP. CLIENT_IP_HEADER names the header to read, and TRUSTED_PROXY sets how many entries to skip from the right.

The client can write anything at the left end of the header. nginx's $proxy_add_x_forwarded_for appends the address it saw on the right, so only the right side can be trusted. With one nginx, set only CLIENT_IP_HEADER=x-forwarded-for and leave TRUSTED_PROXY at 0. When a CDN is added in front, set it to 1.

Unless CLIENT_IP_HEADER is set, mon does not look at any header. A forgotten setting never results in a forged value being taken.

In a handler, use it like this.

async fn where_from(ClientIp(address): ClientIp) -> String {
    address.map(|value| value.to_string()).unwrap_or_default()
}

server::run hands the TCP peer to ClientIp. With axum::serve, pass into_make_service_with_connect_info::<SocketAddr>() to get the same. Without either, the header alone decides, and if that is missing too, the value is None. The resolved address also goes into the log span.

Sessions

The cookie holds only the ID, and the contents are saved in a store. SESSION_STORE chooses the store. sqlite uses the session table in mon.db, file writes one JSON file per session, memory keeps sessions inside the process, and none uses no sessions.

The memory store is emptied by a restart, and with two processes, a session goes missing in one of them. It is for development. SESSION_MEMORY_LIMIT caps how many sessions it holds. When it is full, a request that would create a new session gets 500.

A session is not issued until it is written. A client that only reads gets nothing created and no Set-Cookie. Only when a handler writes to the session does mon create an ID, save it and send the cookie. Otherwise one record would accumulate for every first-time visitor, crawlers included. A route where an anonymous client can write to its session still adds one record per request. Keep the number of such requests down in the proxy, for example with nginx's limit_req.

The ID is 32 random bytes written in hex. mon decides by whether the ID exists in the store, so it does not sign it. An ID sent by the client is used only when the store actually holds it. An unknown ID is not adopted; mon creates a new one instead.

The stores keep the SHA-256 of the ID instead of the ID itself. A leaked table, file or backup therefore does not reveal any cookie value.

The file store creates its directory with mode 0700 and each file with 0600, so other accounts on the machine cannot read them. It writes a temporary file and renames it into place, so a reader never sees a half-written file. The sweep removes only files named the way mon names them, and leaves everything else in the directory alone.

SESSION_SECURE defaults to false so that mon runs on http://localhost in development. Set SESSION_SECURE=true in production. Browsers discard a SESSION_SAME_SITE=none cookie without Secure, so mon refuses to start with that combination.

Expired sessions are removed when read, and by a sweep every hour. A session past half its lifetime is saved again even without changes, which extends it.

Set-Cookie is sent only when a session is saved. Responses that only read get none. Sending it every time would keep even static file responses out of shared caches and CDNs. When Set-Cookie is sent, mon adds private to Cache-Control and removes public and s-maxage. Shared caches may store a response that carries Set-Cookie (RFC 9111), and would then hand one person's sid to another.

Where the client's status changes, such as at login, call session.renew().await to change the ID. The contents stay, and the old ID is removed from the store. Without this, an attacker can place an ID they obtained themselves into the victim's browser and pass authentication with it. mon never adopts an unknown ID, which stops forged IDs, but an ID obtained legitimately can only be made useless by changing it.

In a handler, use it like this.

async fn login(session: Session) -> ... {
    session.write().await.language = Some("ja".to_owned())
}

Calling write() marks the session to be saved. read() only looks and does not save. write() returns a copy of the contents, which is written back when it is dropped. While it is held, read(), renew() and destroy() still return at once, and read() shows the contents before the write-back. Holding two results of write() at the same time panics, because the one dropped first would be lost.

Add the fields a session holds to SessionData in session.rs. They are saved as JSON, so adding fields changes nothing in the stores.

session::router() accepts a language of up to 35 letters, digits and hyphens, and answers 422 otherwise. It returns only language, so fields added to SessionData are not shown to the client.

If saving what a handler wrote fails, mon answers 500 instead of the handler's response, so that the client does not believe it was saved. The same goes for destroy() when the session cannot be removed from the store.

Logging

log::start(default) sends logs to standard output. Writing happens on a dedicated thread, so request handling does not stop even when the output blocks. The span of each request records the path without the query string, because query strings can carry tokens.

// keep the guard until the end. Dropping it stops the writer thread and loses lines not yet written
let _log = log::start("info,tower_http=debug");

RUST_LOG, when set, takes precedence over the level given here. When writing cannot keep up, lines are dropped instead of waiting, so that request handling never stops. The buffer holds 128,000 lines, and lines beyond that are lost. dropped() tells how many were lost. Nothing reports it unless you ask.

The library does not install this on its own, so the application decides whether to call it. To set it up yourself, use tracing_appender::non_blocking directly instead of this function.

Relay

The path and query after /relay/ are joined to RELAY_ORIGIN and forwarded there. The method, headers and body are sent as they are, and the status, headers and body that come back are returned as they are. Only headers about the connection itself (connection, host, transfer-encoding and so on) are neither sent nor returned. Redirects are not followed. A 302 from the upstream reaches the client as it is, location included. Following it would deliver only the final response to the client and lose the original 302.

Whether CLIENT_IP_HEADER is set also tells mon whether a proxy stands in front of it. When it is set, X-Forwarded-For, -Proto and -Host are added only when missing. The ones present were added by the proxy, and rewriting them would shift the entries the upstream counts. When it is empty, mon regards itself as the front. It drops every X-Forwarded-*, Forwarded and X-Real-IP header the client sent, and writes X-Forwarded-For, -Proto and -Host itself. The host header is not forwarded, so without these headers the upstream cannot learn the public host. The status depends on the upstream: 502 when it cannot be reached, and 504 when it accepts the connection but does not answer. Timeouts apply to two waits: until the connection is made, and until the next chunk arrives. A timeout on the whole response would cut off a large response in the middle of streaming.

mon's session cookie is removed from the Cookie header sent to the upstream. A Set-Cookie from the upstream for that cookie name is dropped as well. Otherwise the upstream would learn session IDs, or could replace the client's session.

Static files

route::assets(&conf) is a service that serves STATIC_DIRECTORY. It adds the value of STATIC_CACHE_CONTROL. The default is no-cache, and an empty value adds nothing.

Without the header, the browser guesses an expiry from the time since Last-Modified and does not ask again until then. Replacing a file under the same name still shows the old one. no-cache does not mean the response is not stored; it means the browser must ask before using it. If the file has not changed, a 304 comes back, so one round trip is enough.

If file names include a hash, use max-age=31536000, immutable.

CSP

The value of CONTENT_SECURITY_POLICY is added to every response as Content-Security-Policy. It is empty by default, and then nothing is added. What a page may load differs between applications, so mon has no default value.

CONTENT_SECURITY_POLICY="default-src 'self'; img-src 'self' data:; frame-ancestors 'none'"

If a handler sets its own policy, that one is kept. To vary it per page, set it in the handler.

frame-ancestors replaces X-Frame-Options. With it in the policy, X-Frame-Options is not needed.

html::document writes its styles directly into a <style> element. With only default-src 'self', the styles of page::not_found stop working, so add style-src 'unsafe-inline' or put your own 404 in place.

Storage

The application prepares its own tables. The only table mon creates is the session table, which session::prepare creates with create table if not exists. mon has no migration system. If the library had one, mon's tables would mix into the application's migrations.

The table name can also be set with SESSION_TABLE. The default is session; change it if a table with that name already exists. Its id column holds the SHA-256 of each session ID.

The compile-time macro sqlx::query! is not used. It would need a database connection or the .sqlx/ cache on every build. Runtime query_as and the FromRow derive are used instead.

Layout

src/
├── lib.rs       the public API
├── main.rs      a binary that runs mon alone
├── conf.rs      settings: Conf::default, and load, which reads the environment
├── state.rs     the state shared by every handler
├── database.rs  the SQLite pool
├── route.rs     routing and middleware (tests are here too)
├── server.rs    listening, with a limit on the time for receiving headers
├── session.rs   sessions (the cookie and the stores)
├── error.rs     the shared error type
├── extract.rs   Json / Path / Query
├── html.rs      HTML building
├── ip.rs        the client IP (how far to trust the headers)
├── log.rs       logging setup (calling it is the application's choice)
├── page.rs      the HTML when no route matches (placing it is the application's choice)
└── relay.rs     the relay to another server

examples/
└── todo.rs      an example with /api/todos and a list page

tests/
├── collision.rs mon claims no paths
├── relay.rs     the relay, checked against an upstream started inside the test
└── server.rs    server::run, checked over real TCP connections

Trying it

Start cargo run --example todo first.

curl -X POST localhost:3000/api/todos -H 'content-type: application/json' -d '{"title":"shopping"}'
curl localhost:3000/api/todos
curl -X PATCH localhost:3000/api/todos/<id> -H 'content-type: application/json' -d '{"done":true}'
curl -i -X DELETE localhost:3000/api/todos/<id>
curl -i -X POST localhost:3000/api/session \
	-H 'content-type: application/json' -d '{"language":"ja"}'

Formatting

rustfmt is not used. rustfmt collapses the two blank lines between functions into one. It also has no option to drop the spaces inside braces, which this code writes as {key: value}. Neither can be expressed in its settings, so the code is aligned by hand.

License

Dual licensed under MIT and Apache-2.0. Take whichever you prefer.