1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# 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](./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, conf, database, page, relay, route, session};
use std::sync::Arc;
let conf = 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?;
// 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: Arc::new(conf),
};
// with ConnectInfo, the TCP peer is used when CLIENT_IP_HEADER is not set
let app = route::wrap(routes, state).into_make_service_with_connect_info::<SocketAddr>();
axum::serve(listener, app).await?;
```
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.
## 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
Every error is JSON of the form `{"error": "..."}`. Panics and timeouts take the same form.
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
- `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_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. 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.
`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()
}
```
To use the TCP peer, pass `into_make_service_with_connect_info::<SocketAddr>()` to `axum::serve`.
Without it, 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 `<id>.json`, `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.
**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.
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.
`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.
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.
Add the fields a session holds to `SessionData` in session.rs.
They are saved as JSON, so adding fields changes nothing in the stores.
## 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.
```
// 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.
`X-Forwarded-For`, `-Proto` and `-Host` are added only when missing.
When present, a proxy in front added them, so mon leaves them alone.
Rewriting them would shift the entries the upstream counts.
This works only when mon is at the front.
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.
## 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.
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)
├── 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
```
## 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.
- [LICENSE-MIT](./LICENSE-MIT)
- [LICENSE-APACHE](./LICENSE-APACHE)