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
//! 環境変数の読み取り。散らばらないよう、ここ 1 箇所にまとめる。
//!
//! 未設定なら既定値を使う。書いてあって読めないときは起動をやめる。
//! 黙って既定値へ落とすと、指定した覚えのないポートで上がり、
//! プロセスは動いているのに前段から見ると 502、という一番わかりにくい形になる。
use anyhow::{anyhow, bail};
use axum::http::{HeaderName, HeaderValue};
use std::env;
use std::fmt::Display;
use std::str::FromStr;
use std::time::Duration;
/// SESSION_STORE に書ける言葉
pub const STORE: &[&str] = &["none", "memory", "file", "sqlite"];
/// SESSION_SAME_SITE に書ける言葉
pub const SAME_SITE: &[&str] = &["lax", "strict", "none"];
/// 起動時に一度だけ読む設定
#[derive(Debug, Clone)]
pub struct Conf {
/// 待ち受けポート。PORT
pub port: u16,
/// SQLite の場所。DATABASE_URL
pub database_url: String,
/// 静的ファイルの置き場。STATIC_DIRECTORY
pub static_directory: String,
/// 静的ファイルに付ける Cache-Control。STATIC_CACHE_CONTROL。空なら付けない
pub static_cache_control: String,
/// 中継先の起点。RELAY_ORIGIN
pub relay_origin: String,
/// CORS で許可するオリジン。CORS_ORIGIN をコンマ区切りで書く。空なら全部許可する
pub cors_origin: Vec<String>,
/// CORS で許可するメソッド。CORS_METHOD をコンマ区切りで書く。空なら既定に任せる
pub cors_method: Vec<String>,
/// CORS で許可するリクエストヘッダ。CORS_HEADER をコンマ区切りで書く。空なら既定に任せる
pub cors_header: Vec<String>,
/// JavaScript から読ませるレスポンスヘッダ。CORS_EXPOSE_HEADER をコンマ区切りで書く
pub cors_expose_header: Vec<String>,
/// プリフライトの結果をブラウザに保存させる秒数。CORS_MAX_AGE。0 なら保存させない
pub cors_max_age: Duration,
/// Cookie 付きのリクエストを許すか。CORS_CREDENTIALS
pub cors_credentials: bool,
/// セッションの置き場。SESSION_STORE。none / memory / file / sqlite
pub session_store: String,
/// file のときの置き場。SESSION_DIRECTORY
pub session_directory: String,
/// sqlite のときの表の名前。SESSION_TABLE
pub session_table: String,
/// セッションのクッキー名。SESSION_COOKIE
pub session_cookie: String,
/// セッションを保つ秒数。SESSION_MAX_AGE
pub session_max_age: Duration,
/// クッキーに Secure を付けるか。SESSION_SECURE
pub session_secure: bool,
/// クッキーの SameSite。SESSION_SAME_SITE。lax / strict / none
pub session_same_site: String,
/// リクエスト ID を入れるヘッダの名前。REQUEST_ID_HEADER。空なら付けない
pub request_id_header: String,
/// 相手の IP を読むヘッダの名前。CLIENT_IP_HEADER。空なら TCP の相手を使う
pub client_ip_header: String,
/// そのヘッダを右から何個読み飛ばすか。TRUSTED_PROXY。nginx が 1 段なら 0
pub trusted_proxy: usize,
/// 応答に付ける Content-Security-Policy。CONTENT_SECURITY_POLICY。空なら付けない
pub content_security_policy: String,
/// リクエストボディの上限バイト数。BODY_LIMIT
pub body_limit: usize,
/// リクエスト 1 本にかける上限。REQUEST_TIMEOUT 秒
pub timeout: Duration,
/// 中継先の応答を待つ上限。RELAY_TIMEOUT 秒
pub relay_timeout: Duration,
}
impl Default for Conf {
fn default() -> Conf {
Conf {
port: 3000,
database_url: "sqlite:mon.db".to_owned(),
static_directory: "static".to_owned(),
static_cache_control: "no-cache".to_owned(),
relay_origin: "http://localhost:6060".to_owned(),
cors_origin: Vec::new(),
cors_method: Vec::new(),
cors_header: Vec::new(),
cors_expose_header: Vec::new(),
cors_max_age: Duration::from_secs(3600),
cors_credentials: false,
session_store: "sqlite".to_owned(),
session_directory: "session".to_owned(),
session_table: "session".to_owned(),
session_cookie: "sid".to_owned(),
session_max_age: Duration::from_secs(60 * 60 * 24 * 30),
session_secure: false,
session_same_site: "lax".to_owned(),
request_id_header: "x-request-id".to_owned(),
client_ip_header: String::new(),
trusted_proxy: 0,
content_security_policy: String::new(),
body_limit: 2 * 1024 * 1024,
timeout: Duration::from_secs(10),
relay_timeout: Duration::from_secs(3),
}
}
}
/// 環境変数を読む。書いていないものは [`Conf::default`] のままにする。
/// 読めないものがあれば、その場で理由を付けて返す。
///
/// 環境変数を使わないなら、[`Conf`] を直接組み立ててもよい。
pub fn load() -> anyhow::Result<Conf> {
let default = Conf::default();
let conf = Conf {
port: number("PORT", default.port, 1)?,
database_url: text("DATABASE_URL", &default.database_url),
static_directory: text("STATIC_DIRECTORY", &default.static_directory),
static_cache_control: text("STATIC_CACHE_CONTROL", &default.static_cache_control),
relay_origin: text("RELAY_ORIGIN", &default.relay_origin),
cors_origin: list("CORS_ORIGIN"),
cors_method: list("CORS_METHOD"),
cors_header: list("CORS_HEADER"),
cors_expose_header: list("CORS_EXPOSE_HEADER"),
cors_max_age: seconds("CORS_MAX_AGE", default.cors_max_age, 0)?,
cors_credentials: flag("CORS_CREDENTIALS", default.cors_credentials)?,
session_store: choice("SESSION_STORE", &default.session_store, STORE)?,
session_directory: text("SESSION_DIRECTORY", &default.session_directory),
session_table: text("SESSION_TABLE", &default.session_table),
session_cookie: text("SESSION_COOKIE", &default.session_cookie),
session_max_age: seconds("SESSION_MAX_AGE", default.session_max_age, 1)?,
session_secure: flag("SESSION_SECURE", default.session_secure)?,
session_same_site: choice("SESSION_SAME_SITE", &default.session_same_site, SAME_SITE)?,
request_id_header: text("REQUEST_ID_HEADER", &default.request_id_header),
client_ip_header: text("CLIENT_IP_HEADER", &default.client_ip_header),
trusted_proxy: number("TRUSTED_PROXY", default.trusted_proxy, 0)?,
content_security_policy: text("CONTENT_SECURITY_POLICY", &default.content_security_policy),
body_limit: number("BODY_LIMIT", default.body_limit, 1)?,
timeout: seconds("REQUEST_TIMEOUT", default.timeout, 1)?,
relay_timeout: seconds("RELAY_TIMEOUT", default.relay_timeout, 1)?,
};
// SameSite=None のクッキーは Secure が付いていないとブラウザが捨てる
if conf.session_same_site == "none" && !conf.session_secure {
bail!("SESSION_SAME_SITE=none には SESSION_SECURE=true が要ります");
}
// クッキー名にそのまま書けない字が入ると、ヘッダが壊れる
if !plain(&conf.session_cookie, true) {
bail!("SESSION_COOKIE={} はクッキー名に使えません", conf.session_cookie);
}
// 表の名前は SQL に直接埋めるので、字を絞る
if !plain(&conf.session_table, false) {
bail!("SESSION_TABLE={} は表の名前に使えません", conf.session_table);
}
// 空なら使わない。書いてあるならヘッダ名として通る字であること
header_name("REQUEST_ID_HEADER", &conf.request_id_header)?;
header_name("CLIENT_IP_HEADER", &conf.client_ip_header)?;
// 空なら付けない。書いてあるならヘッダの値として通る字であること
header_value("STATIC_CACHE_CONTROL", &conf.static_cache_control)?;
header_value("CONTENT_SECURITY_POLICY", &conf.content_security_policy)?;
for origin in &conf.cors_origin {
check_cors_origin(origin)?;
}
Ok(conf)
}
/// CORS_ORIGIN の 1 つを確かめる。
///
/// 許可の判定はブラウザが送ってくる Origin との文字列の一致なので、1 字違えば一致しない。
/// 通してしまうと、書いたのに効かない、という一番わかりにくい形で残る。
/// `*` は tower-http の `AllowOrigin::list` が panic するので、ここで止める。
fn check_cors_origin(origin: &str) -> anyhow::Result<()> {
if origin == "*" {
bail!("CORS_ORIGIN=* は書けません。全部許可するなら空にしてください");
}
let (scheme, host) = origin.split_once("://").unwrap_or(("", ""));
if scheme.is_empty() || host.is_empty() {
bail!("CORS_ORIGIN={origin} は scheme://host の形にしてください");
}
if host.contains('/') {
bail!("CORS_ORIGIN={origin} にパスは付けられません。末尾のスラッシュも外します");
}
if origin != origin.to_lowercase() {
bail!("CORS_ORIGIN={origin} は小文字で書いてください。ブラウザは小文字で送ります");
}
Ok(())
}
/// 空なら使わない。書いてあるならヘッダ名として通る字であること
fn header_name(name: &str, value: &str) -> anyhow::Result<()> {
if !value.is_empty() && HeaderName::from_str(value).is_err() {
bail!("{name}={value} はヘッダ名に使えません");
}
Ok(())
}
/// 空なら付けない。書いてあるならヘッダの値として通る字であること
fn header_value(name: &str, value: &str) -> anyhow::Result<()> {
if !value.is_empty() && HeaderValue::from_str(value).is_err() {
bail!("{name}={value} はヘッダの値に使えません");
}
Ok(())
}
/// 秒として読む
fn seconds(name: &str, default: Duration, least: u64) -> anyhow::Result<Duration> {
Ok(Duration::from_secs(number(name, default.as_secs(), least)?))
}
/// 英数字と `_`(クッキー名は `-` も)だけでできているか
fn plain(value: &str, hyphen: bool) -> bool {
!value.is_empty()
&& value
.chars()
.all(|letter| {
letter.is_ascii_alphanumeric() || letter == '_' || (hyphen && letter == '-')
})
}
/// 決まった言葉のどれかとして読む
fn choice(name: &str, default: &str, allowed: &[&str]) -> anyhow::Result<String> {
let Ok(source) = env::var(name) else {
return Ok(default.to_owned());
};
let value = source.to_lowercase();
if !allowed.contains(&value.as_str()) {
bail!("{name}={source} は {} のどれかにしてください", allowed.join(" / "));
}
Ok(value)
}
fn text(name: &str, default: &str) -> String {
env::var(name).unwrap_or_else(|_| default.to_owned())
}
/// 数として読む。`least` より小さい値も書き間違いとして弾く。
/// PORT=0 は OS が空きポートを勝手に選ぶので、明示されたら誤りとみなす。
fn number<T>(name: &str, default: T, least: T) -> anyhow::Result<T>
where
T: FromStr + PartialOrd + Display,
T::Err: Display,
{
let Ok(source) = env::var(name) else {
return Ok(default);
};
let value: T = source
.parse()
.map_err(|error| anyhow!("{name}={source} を数として読めません({error})"))?;
if value < least {
bail!("{name}={source} は {least} 以上にしてください");
}
Ok(value)
}
fn flag(name: &str, default: bool) -> anyhow::Result<bool> {
let Ok(source) = env::var(name) else {
return Ok(default);
};
match source.to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => bail!("{name}={source} は true か false で書いてください"),
}
}
fn list(name: &str) -> Vec<String> {
env::var(name)
.unwrap_or_default()
.split(',')
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_origin_passes() {
assert!(check_cors_origin("https://rollpie.com").is_ok());
assert!(check_cors_origin("http://localhost:3000").is_ok());
}
/// tower-http の AllowOrigin::list はワイルドカードで panic する
#[test]
fn a_wildcard_is_refused() {
assert!(check_cors_origin("*").is_err());
}
/// 末尾のスラッシュが 1 つ違うだけで、ブラウザの Origin と一致しない
#[test]
fn a_path_is_refused() {
assert!(check_cors_origin("https://rollpie.com/").is_err());
assert!(check_cors_origin("https://rollpie.com/page").is_err());
}
#[test]
fn a_broken_shape_is_refused() {
assert!(check_cors_origin("rollpie.com").is_err());
assert!(check_cors_origin("://rollpie.com").is_err());
assert!(check_cors_origin("https://").is_err());
}
/// ブラウザは小文字で送るので、大文字混じりは一致しない
#[test]
fn an_uppercase_origin_is_refused() {
assert!(check_cors_origin("https://Rollpie.com").is_err());
}
}