Harden sign-in and rounded shares, drop the test CLI
Rounded shares: - Track queries cover whole time buckets, so moving from and to does not reveal raw times. The range check no longer overflows. - The coarse cell is chosen at upload and kept until the position is a quarter cell past the edge, so GPS noise at an edge does not reveal it. Precision is limited to fixed levels. - Rounded shares hide the battery level. Sign-in: - Password limits group IPv6 by /64 and add a limit per name or guest link across all addresses. Addresses that signed in recently skip that shared limit. - Credential changes and admin user changes need a sign-in in the last 10 minutes. - Passkey sign-in starts are rate limited. - The last-admin check runs in the same statement as the change. - A password reset runs in one transaction. Pairing codes end with the session that created them. - Responses carry a CSP and other security headers. The ot CLI is gone. The README shows curl instead, and the web UI shows the server address and token. Smaller cleanups: Point derives Default, one query in guest unlock, shared helpers, no uuid or hex crates, one list helper in settings, no positional i18n placeholders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
D.cargo/config.toml-3
@@ -1,3 +0,0 @@
# reqwest gates HTTP/3 (used by the CLI) behind this cfg.
[target.'cfg(not(target_arch = "wasm32"))']
rustflags = ["--cfg", "reqwest_unstable"]
MCargo.lock
@@ -47,7 +47,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -206,29 +206,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "axum"
version = "0.8.9"
@@ -299,12 +276,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -380,8 +351,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -391,23 +360,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -448,27 +400,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cli"
version = "0.1.0"
dependencies = [
"api",
"getrandom 0.4.3",
"reqwest",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -498,16 +429,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "config"
version = "0.15.27"
@@ -597,22 +518,6 @@ dependencies = [
"convert_case 0.11.0",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -741,12 +646,6 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.18.0"
@@ -782,7 +681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -817,24 +716,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -865,12 +752,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.34"
@@ -969,19 +850,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1004,7 +872,6 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1048,53 +915,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"quinn",
"tokio",
"tokio-util",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -1234,7 +1054,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1243,22 +1062,6 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
@@ -1267,22 +1070,13 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -1405,12 +1199,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8"
[[package]]
name = "ipnet"
version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1432,65 +1220,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -1690,12 +1419,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "manyhow"
version = "0.11.4"
@@ -1761,7 +1484,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -1870,12 +1593,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
@@ -2088,64 +1805,6 @@ dependencies = [
"yansi",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2196,18 +1855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
"rand_core",
]
[[package]]
@@ -2217,7 +1865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
"rand_core",
]
[[package]]
@@ -2229,21 +1877,6 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "reactive_graph"
version = "0.2.15"
@@ -2335,60 +1968,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
"base64 0.23.1",
"bytes",
"futures-core",
"h2",
"h3",
"h3-quinn",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
@@ -2453,81 +2032,6 @@ dependencies = [
"nom",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -2549,44 +2053,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -2705,16 +2177,15 @@ dependencies = [
"api",
"argon2",
"axum",
"base64 0.22.1",
"clap",
"getrandom 0.4.3",
"hex",
"rusqlite",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tower-http 0.7.1",
"uuid",
"tower-http",
"webauthn-rs",
"webauthn-rs-proto",
]
@@ -2815,22 +2286,6 @@ dependencies = [
"libc",
]
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2859,7 +2314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -2886,12 +2341,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -2931,9 +2380,6 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -3078,12 +2524,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -3098,7 +2538,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -3112,16 +2552,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3131,7 +2561,6 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
@@ -3191,24 +2620,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-http"
version = "0.7.1"
@@ -3278,12 +2689,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-builder"
version = "0.23.2"
@@ -3334,12 +2739,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -3399,15 +2798,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3540,16 +2930,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.5"
@@ -3591,7 +2971,7 @@ dependencies = [
"nom",
"openssl",
"openssl-sys",
"rand 0.9.5",
"rand",
"rand_chacha",
"serde",
"serde_cbor_2",
@@ -3618,22 +2998,13 @@ dependencies = [
"url",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -3642,15 +3013,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -3660,70 +3022,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.4"
@@ -3838,12 +3136,6 @@ dependencies = [
"synstructure 0.14.0",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
MCargo.toml
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/api", "crates/server", "crates/cli", "web"]
members = ["crates/api", "crates/server", "web"]
resolver = "3"
[workspace.package]
MREADME.md
@@ -3,9 +3,8 @@
Self-hosted location sharing. Devices upload positions over HTTPS. A web map shows your position and the positions others share with you.
```
crates/api JSON types shared by server, CLI and web
crates/api JSON types shared by server and web
crates/server otserver: axum + SQLite, serves the API and web/dist
crates/cli ot: test client (register a device, send points, simulate a walk)
web/ Leptos + Leaflet, built with Trunk
android/ Android app: background tracking, this device's map, see docs/android.md
```
@@ -15,10 +14,22 @@ android/ Android app: background tracking, this device's map, see docs/and
```sh
cd web && trunk build && cd .. # or `trunk serve`: live reload on :8081, API proxied to :8080
cargo run -p server # http://localhost:8080, the first visit creates the admin account
```
Send points and read the position as a device:
cargo run -p cli -- use-token http://localhost:8080 <token from Settings → Devices>
cargo run -p cli -- simulate --interval 2 --batch 5 48.137 11.575
cargo run -p cli -- position
```sh
URL=http://localhost:8080 TOKEN=... # a token from Settings → Devices
curl -H "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":48.137,\"lon\":11.575}]" $URL/api/points
curl -H "Authorization: Bearer $TOKEN" $URL/api/device
# a random walk, one point every 2 seconds
lat=48.137 lon=11.575
while sleep 2; do
set -- $(LC_ALL=C awk -v a=$lat -v o=$lon 'BEGIN { srand(); printf "%.6f %.6f", a + (rand() - .5) / 1000, o + (rand() - .5) / 1000 }')
lat=$1 lon=$2
curl -sH "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":$lat,\"lon\":$lon}]" $URL/api/points
done
```
### Android app
@@ -53,7 +64,7 @@ Every flag can also be set by its environment variable. `otserver --help` lists
`--public-url` matters behind a reverse proxy:
- Passkeys are bound to this address. Without it the server uses the request's Host header and assumes plain HTTP.
- An `https://` URL marks the session cookie `Secure`.
- The web UI shows it in the device setup commands.
- The web UI shows it as the server address for devices.
The server updates the database schema at start. Back up the database file before you upgrade.
@@ -64,7 +75,8 @@ The server updates the database schema at start. Back up the database file befor
- The first visit to a server with no users shows a setup form for the admin account. Anyone who reaches the server first can claim it, so set it up before you expose it.
- Admins add and delete users, change their role and reset passwords under Settings → Users. Admins cannot change their own role, so one admin always remains.
- Each user picks a sign-in mode under Settings → Security: password **or** passkey, or password **and** passkey (two-factor), in either order. A user with a passkey can remove the password.
- Wrong passwords are limited per client address: 5 per username and 30 across all usernames, then a 15-minute lockout.
- Wrong passwords are limited: 5 per username from all client addresses together, and 30 per client address across all usernames, then a 15-minute lockout. An IPv6 /64 counts as one address. An address that signed in to an account in the last 30 days skips the shared limit, so others cannot lock the owner out. It keeps its own limit of 5.
- Changes to how you sign in (password, two-factor, passkeys) and admin changes to users need a sign-in in the last 10 minutes. Otherwise sign out and sign in again.
- Devices sign in with a token. The Android app gets one by signing in through the browser. For other clients, create one under Settings → Devices.
## Devices and sharing
@@ -75,7 +87,7 @@ The server updates the database schema at start. Back up the database file befor
- A share chooses what the viewer sees:
- all devices, including ones added later, or only selected devices,
- only the current position, or the trail from now on, since a chosen time, or the full history,
- the exact position, or one rounded to about 100 m, 1 km or 10 km. Rounding also rounds the times, so the moment of moving into the next cell does not give the position away.
- the exact position, or one rounded to about 100 m, 1 km or 10 km. Rounding also rounds the times, so the trail does not show the moment of moving into the next cell. The shown cell changes only when the position is clearly inside the next cell, so GPS noise near an edge does not reveal it. A viewer who watches the current position still sees the change soon after it happens. Rounded shares do not show the battery.
- Sharing again with the same person replaces the settings.
- A guest link shares with anyone who has the link, without an account. It has the same choices, plus an optional password. The link has the form `https://track.example.com/#l=<token>`. The token stays after the `#`, so it does not reach server or proxy logs when the page loads.
@@ -93,7 +105,7 @@ track.example.com {
}
```
HTTP/3 needs UDP 443 open next to TCP 443. `ot --http3 position` prints `[HTTP/3.0]` when it works.
HTTP/3 needs UDP 443 open next to TCP 443. `curl --http3-only -sI https://track.example.com/healthz` prints `HTTP/3 200` when it works.
Set `OT_BEHIND_PROXY=true`, so the password limits see the real client address. The server then takes the last `X-Forwarded-For` entry, the one the proxy wrote. Clients must not reach the server port directly, or they could set that header themselves. With several proxies in a row, the limits see the outer proxy's address.
@@ -105,7 +117,7 @@ Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>
|---|---|---|
| `GET/POST /api/setup` | none | first-boot admin account |
| `POST /api/login`, `/api/logout` | password | can answer with a passkey challenge (two-factor) |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Can ask for the password next (two-factor) |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Can ask for the password next (two-factor). At most 30 starts per client address in 15 minutes |
| `GET /api/me` | session | |
| `POST/DELETE /api/me/password`, `PUT /api/me/two-factor`, `PUT /api/me/retention` | session | |
| `GET /api/passkeys`, `POST /api/passkeys/register[/finish]`, `DELETE /api/passkeys/{id}` | session | |
@@ -119,7 +131,7 @@ Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>
| `POST /api/guest`, `/api/guest/track` | link token (+ key) | what a guest link shows. 401 means the link needs its password |
| `POST /api/guest/unlock` | link token + password | returns the key for a password-protected link. 5 failures lock the link for 15 minutes |
| `GET/POST /api/users`, `DELETE /api/users/{id}`, `PUT /api/users/{id}/role`, `POST /api/users/{id}/password` | admin | |
| `POST /api/devices/pair/begin` | session | `{challenge, name}` → one-time code for the app, valid 5 minutes |
| `POST /api/devices/pair/begin` | session | `{challenge, name}` → one-time code for the app, valid 5 minutes and while the session that asked for it lasts |
| `POST /api/devices/pair` | code + verifier | the app exchanges the code and the secret behind the challenge for a device token. Each code works once. |
| `GET /api/device`, `GET /api/device/track?from=&to=` | device token | the app's own position and trail. The web UI's `#device` page uses them inside the app. |
| `POST /api/points` | device token or session | JSON array of points, at most 1000. Returns how many were stored and skipped. Invalid points are skipped, so one bad point cannot block a client's queue. Duplicates (same device and second) are ignored, so a client can retry a batch. A session uploads as the "Web" device. |
Mcrates/api/src/lib.rs
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
/// Unix seconds.
pub type Ts = i64;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct Point {
pub ts: Ts,
pub lat: f64,
@@ -217,7 +217,7 @@ pub struct ShareSettings {
/// The viewer sees the trail from this time on. 0 is the full history. None shows only the current position.
#[serde(default)]
pub trail_since: Option<Ts>,
/// Rounds positions to about this many metres. 0 means exact.
/// Rounds positions to about this many metres: 0 for exact, or one of PRECISIONS_M.
#[serde(default)]
pub precision_m: u32,
}
@@ -328,8 +328,8 @@ pub struct ResetPassword {
/// Upper bound for one upload, so one request cannot hold the database for long.
pub const MAX_BATCH: usize = 1000;
/// Coarsest share precision.
pub const MAX_PRECISION_M: u32 = 100_000;
/// The precisions a share can round to, besides 0 for exact.
pub const PRECISIONS_M: [u32; 4] = [100, 1000, 10_000, 100_000];
/// Longest time range one track request may cover.
pub const MAX_TRACK_SECS: Ts = 31 * 86400;
Dcrates/cli/Cargo.toml-16
@@ -1,16 +0,0 @@
[package]
name = "cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "ot"
path = "src/main.rs"
[dependencies]
api.workspace = true
getrandom = { version = "0.4.3", default-features = false }
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "http2", "http3"] }
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1.53.1", default-features = false, features = ["rt", "macros", "time"] }
Dcrates/cli/src/main.rs-268
@@ -1,268 +0,0 @@
//! `ot`: a test client for the opentracker API.
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use api::{MAX_BATCH, Person, Point, Uploaded};
use serde::{Deserialize, Serialize};
const USAGE: &str = "usage: ot [--http3] [--insecure] <command>
use-token <url> <token> use a device token created in the web UI
send <lat> <lon> upload one point
simulate [--interval S] [--batch N] [lat lon]
random walk, one point every S seconds (default 2),
uploaded in batches of N (default 5)
position show this device's last position
--http3 use HTTP/3 only. Needs an HTTPS reverse proxy that speaks it.
--insecure accept any TLS certificate, for a local proxy with its own CA.
config file: $OT_CONFIG, default ~/.config/ot/config.json";
#[derive(Serialize, Deserialize)]
struct Config {
url: String,
token: String,
}
fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("OT_CONFIG") {
return p.into();
}
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").expect("HOME")).join(".config"));
base.join("ot/config.json")
}
fn load_config() -> Config {
let path = config_path();
let text = std::fs::read_to_string(&path).unwrap_or_else(|_| {
fail(&format!(
"no config at {}. Run `ot use-token` first.",
path.display()
))
});
serde_json::from_str(&text).expect("valid config file")
}
fn save_config(c: &Config) {
use std::os::unix::fs::OpenOptionsExt;
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap()).expect("create config dir");
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.expect("write config");
serde_json::to_writer_pretty(file, c).expect("write config");
println!("saved {}", path.display());
}
fn fail(msg: &str) -> ! {
eprintln!("{msg}");
std::process::exit(1);
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
/// Removes `--name value` from `args` and returns the value.
fn take_opt(args: &mut Vec<String>, name: &str) -> Option<String> {
let i = args.iter().position(|a| a == name)?;
if i + 1 >= args.len() {
fail(&format!("{name} needs a value"));
}
args.remove(i);
Some(args.remove(i))
}
fn take_flag(args: &mut Vec<String>, name: &str) -> bool {
let found = args.iter().any(|a| a == name);
args.retain(|a| a != name);
found
}
fn num<T: std::str::FromStr>(s: &str) -> T {
s.parse()
.unwrap_or_else(|_| fail(&format!("not a number: {s}")))
}
struct Http {
client: reqwest::Client,
http3: bool,
}
impl Http {
fn get(&self, url: String) -> reqwest::RequestBuilder {
self.version(self.client.get(url))
}
fn post(&self, url: String) -> reqwest::RequestBuilder {
self.version(self.client.post(url))
}
/// reqwest picks HTTP/3 per request, not per client.
fn version(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if self.http3 {
req.version(reqwest::Version::HTTP_3)
} else {
req
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let http3 = take_flag(&mut args, "--http3");
let insecure = take_flag(&mut args, "--insecure");
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.danger_accept_invalid_certs(insecure);
if http3 {
builder = builder.http3_prior_knowledge();
}
let client = Http {
client: builder.build().expect("HTTP client"),
http3,
};
let interval = take_opt(&mut args, "--interval").map_or(2.0, |s| num::<f64>(&s));
let batch = take_opt(&mut args, "--batch").map_or(5, |s| num::<usize>(&s));
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match args[..] {
["use-token", url, token] => save_config(&Config {
url: url.trim_end_matches('/').into(),
token: token.into(),
}),
["send", lat, lon] => {
let p = Point {
ts: now(),
..point(num(lat), num(lon))
};
let up = upload(&client, &load_config(), &[p])
.await
.unwrap_or_else(|e| fail(&e));
println!("stored {}, skipped {}", up.stored, up.skipped);
}
["simulate"] => simulate(&client, 48.1372, 11.5754, interval, batch).await,
["simulate", lat, lon] => simulate(&client, num(lat), num(lon), interval, batch).await,
["position"] => {
let cfg = load_config();
let res = client
.get(format!("{}/api/device", cfg.url))
.bearer_auth(&cfg.token)
.send()
.await
.unwrap_or_else(|e| fail(&format!("{e:?}")));
let res = check(res).await.unwrap_or_else(|e| fail(&e));
print_person(&res.json().await.expect("person response"));
}
_ => fail(USAGE),
}
}
async fn check(res: reqwest::Response) -> Result<reqwest::Response, String> {
if res.status().is_success() {
return Ok(res);
}
let status = res.status();
let body = res.text().await.unwrap_or_default();
Err(format!("{status} {body}"))
}
async fn upload(client: &Http, cfg: &Config, points: &[Point]) -> Result<Uploaded, String> {
let res = client
.post(format!("{}/api/points", cfg.url))
.bearer_auth(&cfg.token)
.json(points)
.send()
.await
.map_err(|e| format!("{e:?}"))?;
let res = check(res).await?;
eprintln!("[{:?}] uploaded {} point(s)", res.version(), points.len());
res.json().await.map_err(|e| e.to_string())
}
fn point(lat: f64, lon: f64) -> Point {
Point {
ts: 0,
lat,
lon,
acc: None,
alt: None,
speed: None,
bearing: None,
battery: None,
}
}
fn print_person(p: &Person) {
{
match p.last() {
Some(d) => {
let l = &d.last;
println!(
"{:<16} {:<12} {:>10.6} {:>11.6} {:>5}s ago acc {:>4} bat {:>3}",
p.username,
d.name,
l.lat,
l.lon,
now() - l.ts,
l.acc.map_or("-".into(), |a| format!("{a:.0}m")),
l.battery.map_or("-".into(), |b| format!("{b}%")),
)
}
None => println!("{:<16} no position yet", p.username),
}
}
}
/// Uniform in [0, 1).
fn rand01() -> f64 {
getrandom::u32().expect("OS random number generator") as f64 / (u32::MAX as f64 + 1.0)
}
async fn simulate(client: &Http, mut lat: f64, mut lon: f64, interval: f64, batch: usize) {
let cfg = load_config();
let speed = 8.0; // m/s, fast enough to see movement on the map
let mut heading = rand01() * 360.0;
let mut pending: Vec<Point> = Vec::new();
let start = now();
let mut tick = tokio::time::interval(Duration::from_secs_f64(interval));
loop {
tick.tick().await;
heading = (heading + (rand01() - 0.5) * 40.0).rem_euclid(360.0);
let dist = speed * interval;
lat += dist * heading.to_radians().cos() / 111_320.0;
lon += dist * heading.to_radians().sin() / (111_320.0 * lat.to_radians().cos());
pending.push(Point {
ts: now(),
acc: Some(5.0 + rand01() as f32 * 10.0),
speed: Some(speed as f32),
bearing: Some(heading as f32),
battery: Some((100 - (now() - start) / 60 % 100) as u8),
..point(lat, lon)
});
// Several points can share one second when the interval is short. The server keeps the first.
pending.dedup_by_key(|p| p.ts);
if pending.len() < batch {
continue;
}
match upload(client, &cfg, &pending).await {
Ok(_) => pending.clear(),
Err(e) => {
eprintln!("upload failed, keeping {} point(s): {e}", pending.len());
if pending.len() > MAX_BATCH {
pending.drain(..pending.len() - MAX_BATCH);
}
}
}
}
}
Mcrates/server/Cargo.toml
@@ -11,16 +11,15 @@ path = "src/main.rs"
api.workspace = true
argon2 = "0.6.0"
axum = "0.8.9"
base64 = "0.22.1"
clap = { version = "4.6.7", features = ["derive", "env"] }
getrandom = "0.4.3"
hex = "0.4.3"
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde.workspace = true
serde_json.workspace = true
sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["full"] }
tower-http = { version = "0.7.1", features = ["fs"] }
uuid = { version = "1.26.1", features = ["v4"] }
webauthn-rs = { version = "0.5.5", default-features = false, features = ["conditional-ui"] }
# Keep equal to the webauthn-rs version. A second copy would compile as different types.
webauthn-rs-proto = "0.5.5"
Mcrates/server/src/auth.rs
@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::sync::{LazyLock, Mutex};
use argon2::Argon2;
@@ -42,11 +42,15 @@ pub fn check_new_password(password: &str) -> Result<(), &'static str> {
pub fn new_secret() -> (String, Vec<u8>) {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("OS random number generator");
let secret = hex::encode(bytes);
let secret = hex(&bytes);
let hash = hash_secret(&secret);
(secret, hash)
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// The secrets are 256 random bits, so a fast hash is enough. Argon2 is only for passwords.
fn hash_secret(secret: &str) -> Vec<u8> {
Sha256::digest(secret.as_bytes()).to_vec()
@@ -95,20 +99,39 @@ pub async fn limited<T: Send + 'static>(
name: &str,
check: impl FnOnce() -> Option<T> + Send + 'static,
) -> Result<T, Error> {
let pair = format!("{ip} {}", name.to_lowercase());
let single = ip.to_string();
state
.limiter
.attempt(&[(&pair, MAX_FAILURES), (&single, MAX_IP_FAILURES)])?;
let single = ip_group(ip);
let name = name.to_lowercase();
let pair = format!("{single} {name}");
let any = format!("any {name}");
let mut keys = vec![
(pair.as_str(), MAX_FAILURES),
(single.as_str(), MAX_IP_FAILURES),
];
// Guesses from many addresses lock the name for new addresses only, so a stranger cannot lock out the owner.
if !state.limiter.known(&pair) {
keys.push((&any, MAX_FAILURES));
}
state.limiter.attempt(&keys)?;
match argon(check).await? {
Some(v) => {
state.limiter.forgive(&pair, &single);
state.limiter.forgive(&pair, &[&single, &any]);
Ok(v)
}
None => Err(Error::Unauthorized),
}
}
/// The limits count an IPv6 /64 as one address, since one client usually holds the whole /64.
pub fn ip_group(ip: IpAddr) -> String {
match ip.to_canonical() {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!(
"{}/64",
Ipv6Addr::from(u128::from(v6) & !(u64::MAX as u128))
),
}
}
/// Each Argon2 run takes about 19 MiB. Without a bound, parallel requests could exhaust the memory.
static ARGON: LazyLock<Semaphore> =
LazyLock::new(|| Semaphore::new(std::thread::available_parallelism().map_or(2, |n| n.get())));
@@ -140,9 +163,10 @@ fn cookie(state: &AppState, value: &str, max_age: i64) -> String {
/// Creates a session and returns its Set-Cookie value.
pub fn create_session(state: &AppState, user_id: i64) -> Result<String, Error> {
let (token, hash) = new_secret();
let now = now();
state.db().execute(
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)",
params![hash, user_id, now() + SESSION_SECS],
"INSERT INTO sessions (token_hash, user_id, expires_at, signed_in_at) VALUES (?1, ?2, ?3, ?4)",
params![hash, user_id, now + SESSION_SECS, now],
)?;
Ok(cookie(state, &token, SESSION_SECS))
}
@@ -160,21 +184,27 @@ pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> {
Ok(())
}
/// Wrong passwords per address and name before a lockout.
/// Wrong passwords per name before a lockout: from one address, and from all addresses together.
const MAX_FAILURES: u32 = 5;
/// Wrong passwords per address across all names, so guessing one password for many users is limited too.
const MAX_IP_FAILURES: u32 = 30;
const LOCKOUT_SECS: i64 = 15 * 60;
/// How long an address that passed a password check stays exempt from the lock across all addresses.
const KNOWN_SECS: i64 = 30 * 86400;
/// Password attempts per key. An attempt counts before the check, so parallel requests cannot slip past the limit.
/// Attempts per key. An attempt counts before the check, so parallel requests cannot slip past the limit.
#[derive(Default)]
pub struct Limiter(Mutex<HashMap<String, (u32, i64)>>);
pub struct Limiter {
counts: Mutex<HashMap<String, (u32, i64)>>,
/// Address and name pairs that passed, with the time.
known: Mutex<HashMap<String, i64>>,
}
impl Limiter {
/// Counts one attempt for every key, or none if any key is at its limit.
pub fn attempt(&self, keys: &[(&str, u32)]) -> Result<(), Error> {
let now = now();
let mut map = self.0.lock().unwrap();
let mut map = self.counts.lock().unwrap();
let locked = keys.iter().any(|(key, max)| {
map.get(*key)
.is_some_and(|&(n, since)| n >= *max && now - since < LOCKOUT_SECS)
@@ -192,22 +222,34 @@ impl Limiter {
Ok(())
}
/// The password was right: earlier failures for the pair are forgotten, and this attempt does not count for the address.
pub fn forgive(&self, pair: &str, single: &str) {
let mut map = self.0.lock().unwrap();
/// The password was right: earlier failures for the pair are forgotten, and this attempt does not count for the other keys.
pub fn forgive(&self, pair: &str, others: &[&str]) {
let mut map = self.counts.lock().unwrap();
map.remove(pair);
if let Some(entry) = map.get_mut(single) {
entry.0 = entry.0.saturating_sub(1);
for key in others {
if let Some(entry) = map.get_mut(*key) {
entry.0 = entry.0.saturating_sub(1);
}
}
self.known.lock().unwrap().insert(pair.to_owned(), now());
}
pub fn known(&self, pair: &str) -> bool {
let known = self.known.lock().unwrap();
known.get(pair).is_some_and(|&t| now() - t < KNOWN_SECS)
}
/// Attackers choose the names, so old entries must go.
pub fn prune(&self) {
let now = now();
self.0
self.counts
.lock()
.unwrap()
.retain(|_, (_, since)| now - *since < LOCKOUT_SECS);
self.known
.lock()
.unwrap()
.retain(|_, t| now - *t < KNOWN_SECS);
}
}
@@ -254,6 +296,23 @@ pub struct User {
pub username: String,
pub is_admin: bool,
pub session_hash: Vec<u8>,
/// When this session proved a password or passkey.
pub signed_in_at: i64,
}
/// How long after sign-in a session may change credentials.
const RECENT_SECS: i64 = 10 * 60;
impl User {
/// Credential changes need a recent sign-in, so a stolen session cannot take over the account.
pub fn check_recent(&self) -> Result<(), Error> {
if now() - self.signed_in_at > RECENT_SECS {
return Err(Error::BadRequest(
"log out and log in again before you change this".into(),
));
}
Ok(())
}
}
impl FromRequestParts<AppState> for User {
@@ -269,13 +328,13 @@ impl FromRequestParts<AppState> for User {
.find_map(|c| c.trim().strip_prefix(SESSION_COOKIE)?.strip_prefix('='))
.ok_or(Error::Unauthorized)?;
let session_hash = hash_secret(token);
let (id, username, is_admin) = state
let (id, username, is_admin, signed_in_at) = state
.db()
.query_row(
"SELECT u.id, u.username, u.is_admin FROM sessions s JOIN users u ON u.id = s.user_id
"SELECT u.id, u.username, u.is_admin, s.signed_in_at FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?1 AND s.expires_at > ?2",
params![session_hash, now()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()?
.ok_or(Error::Unauthorized)?;
@@ -284,6 +343,7 @@ impl FromRequestParts<AppState> for User {
username,
is_admin,
session_hash,
signed_in_at,
})
}
}
@@ -316,20 +376,18 @@ impl FromRequestParts<AppState> for Uploader {
let d = Device::from_request_parts(parts, state).await?;
return Ok(Uploader { device_id: d.id });
}
{
let user = User::from_request_parts(parts, state).await?;
let db = state.db();
db.execute(
"INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)",
params![user.id, now()],
)?;
let device_id = db.query_row(
"SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL",
[user.id],
|r| r.get(0),
)?;
Ok(Uploader { device_id })
}
let user = User::from_request_parts(parts, state).await?;
let db = state.db();
db.execute(
"INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)",
params![user.id, now()],
)?;
let device_id = db.query_row(
"SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL",
[user.id],
|r| r.get(0),
)?;
Ok(Uploader { device_id })
}
}
@@ -378,7 +436,7 @@ mod tests {
Err(Error::TooManyRequests)
));
assert!(l.attempt(&keys("ip b")).is_ok());
l.forgive("ip a", "ip");
l.forgive("ip a", &["ip"]);
assert!(l.attempt(&keys("ip a")).is_ok());
for n in 0..MAX_IP_FAILURES {
let pair = format!("ip {n}");
@@ -390,6 +448,51 @@ mod tests {
));
}
#[test]
fn a_name_locks_across_addresses_but_not_for_known_ones() {
let state = crate::test_state();
let rt = tokio::runtime::Runtime::new().unwrap();
let guess = |ip: &str, ok: bool| {
rt.block_on(limited(&state, ip.parse().unwrap(), "Alice", move || {
ok.then_some(())
}))
};
assert!(guess("198.51.100.1", true).is_ok());
for n in 0..MAX_FAILURES {
assert!(matches!(
guess(&format!("2001:db8:{n}::1"), false),
Err(Error::Unauthorized)
));
}
assert!(matches!(
guess("2001:db8:99::1", true),
Err(Error::TooManyRequests)
));
assert!(guess("198.51.100.1", true).is_ok());
}
#[test]
fn ipv6_addresses_group_by_64() {
let group = |s: &str| ip_group(s.parse().unwrap());
assert_eq!(group("2001:db8:1:2:aaaa::1"), group("2001:db8:1:2:bbbb::2"));
assert_ne!(group("2001:db8:1:2::1"), group("2001:db8:1:3::1"));
assert_eq!(group("::ffff:203.0.113.7"), "203.0.113.7");
}
#[test]
fn credential_changes_need_a_recent_sign_in() {
let user = |signed_in_at| User {
id: 1,
username: "a".into(),
is_admin: false,
session_hash: vec![],
signed_in_at,
};
assert!(user(now() - 60).check_recent().is_ok());
assert!(user(now() - RECENT_SECS - 1).check_recent().is_err());
assert!(user(0).check_recent().is_err());
}
#[test]
fn forwarded_ip_takes_the_entry_the_proxy_wrote() {
let proxy: IpAddr = "10.0.0.2".parse().unwrap();
Mcrates/server/src/device.rs
@@ -6,7 +6,7 @@ use std::sync::Mutex;
use api::{DeviceToken, PairBegin, PairCode, PairFinish, Person, Point};
use axum::Json;
use axum::extract::{Query, State};
use rusqlite::Connection;
use rusqlite::{Connection, params};
use serde::Deserialize;
use sha2::{Digest, Sha256};
@@ -58,6 +58,7 @@ const PAIR_SECS: i64 = 300;
struct Pairing {
user_id: i64,
session_hash: Vec<u8>,
challenge: String,
name: String,
expires_at: i64,
@@ -68,13 +69,14 @@ struct Pairing {
pub struct Pairings(Mutex<HashMap<String, Pairing>>);
impl Pairings {
fn put(&self, user_id: i64, challenge: String, name: String) -> String {
fn put(&self, user: &User, challenge: String, name: String) -> String {
let (code, _) = auth::new_secret();
let now = now();
let mut map = self.0.lock().unwrap();
map.retain(|_, p| p.expires_at > now);
let p = Pairing {
user_id,
user_id: user.id,
session_hash: user.session_hash.clone(),
challenge,
name,
expires_at: now + PAIR_SECS,
@@ -84,10 +86,10 @@ impl Pairings {
}
/// The code is gone after one attempt, so a wrong verifier cannot be retried.
fn take(&self, code: &str, verifier: &str) -> Option<(i64, String)> {
fn take(&self, code: &str, verifier: &str) -> Option<Pairing> {
let p = self.0.lock().unwrap().remove(code)?;
let challenge = hex::encode(Sha256::digest(verifier.as_bytes()));
(p.expires_at > now() && challenge == p.challenge).then_some((p.user_id, p.name))
let challenge = auth::hex(&Sha256::digest(verifier.as_bytes()));
(p.expires_at > now() && challenge == p.challenge).then_some(p)
}
}
@@ -105,7 +107,7 @@ pub async fn pair_begin(
}
let name = check_device_name(&b.name)?.to_owned();
Ok(Json(PairCode {
code: s.pairings.put(user.id, challenge, name),
code: s.pairings.put(&user, challenge, name),
}))
}
@@ -113,11 +115,21 @@ pub async fn pair_finish(
State(s): State<AppState>,
Json(b): Json<PairFinish>,
) -> Result<Json<DeviceToken>> {
let (user_id, name) = s
let p = s
.pairings
.take(&b.code, &b.verifier)
.ok_or(Error::NotFound)?;
Ok(Json(insert_device(&s.db(), user_id, &name)?))
let db = s.db();
// A code lives only as long as the session that began it. Sign-outs and password resets end it too.
let live: bool = db.query_row(
"SELECT EXISTS (SELECT 1 FROM sessions WHERE token_hash = ?1 AND user_id = ?2 AND expires_at > ?3)",
params![p.session_hash, p.user_id, now()],
|r| r.get(0),
)?;
if !live {
return Err(Error::NotFound);
}
Ok(Json(insert_device(&db, p.user_id, &p.name)?))
}
#[cfg(test)]
@@ -127,12 +139,56 @@ mod tests {
#[test]
fn a_code_needs_its_verifier_and_works_once() {
let p = Pairings::default();
let challenge = hex::encode(Sha256::digest(b"secret"));
let code = p.put(7, challenge.clone(), "phone".into());
assert_eq!(p.take(&code, "secret"), Some((7, "phone".into())));
assert_eq!(p.take(&code, "secret"), None);
let code = p.put(7, challenge, "phone".into());
assert_eq!(p.take(&code, "guess"), None);
assert_eq!(p.take(&code, "secret"), None);
let user = User {
id: 7,
username: "a".into(),
is_admin: false,
session_hash: vec![1],
signed_in_at: 0,
};
let challenge = auth::hex(&Sha256::digest(b"secret"));
let code = p.put(&user, challenge.clone(), "phone".into());
assert!(
p.take(&code, "secret")
.is_some_and(|p| p.user_id == 7 && p.name == "phone")
);
assert!(p.take(&code, "secret").is_none());
let code = p.put(&user, challenge, "phone".into());
assert!(p.take(&code, "guess").is_none());
assert!(p.take(&code, "secret").is_none());
}
#[tokio::test]
async fn a_code_dies_with_its_session() {
let s = crate::test_state();
let id = crate::insert_user(&s.db(), "a", "h", false).unwrap();
s.db()
.execute(
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'01', ?1, 9999999999)",
[id],
)
.unwrap();
let user = User {
id,
username: "a".into(),
is_admin: false,
session_hash: vec![1],
signed_in_at: 0,
};
let pair = |code: String| {
pair_finish(
State(s.clone()),
Json(PairFinish {
code,
verifier: "secret".into(),
}),
)
};
let challenge = auth::hex(&Sha256::digest(b"secret"));
let code = s.pairings.put(&user, challenge.clone(), "phone".into());
assert!(pair(code).await.is_ok());
let code = s.pairings.put(&user, challenge, "phone".into());
crate::reset_password(&mut s.db(), id, "new").unwrap();
assert!(matches!(pair(code).await, Err(Error::NotFound)));
}
}
Mcrates/server/src/guest.rs
@@ -51,7 +51,7 @@ fn open(db: &Connection, auth: &GuestAuth) -> Result<Guest> {
/// The password hash carries a random salt, so only the server can derive this.
fn key(token: &str, pw_hash: &str) -> String {
hex::encode(Sha256::digest(format!("{token}\n{pw_hash}").as_bytes()))
auth::hex(&Sha256::digest(format!("{token}\n{pw_hash}").as_bytes()))
}
pub async fn view(State(s): State<AppState>, Json(b): Json<GuestAuth>) -> Result<Json<GuestView>> {
@@ -79,27 +79,23 @@ pub async fn unlock(
auth::ClientIp(ip): auth::ClientIp,
Json(b): Json<GuestUnlock>,
) -> Result<Json<GuestKey>> {
let auth = GuestAuth {
token: b.token,
key: None,
};
let opened = open(&s.db(), &auth);
let hash = match opened {
Ok(_) => return Err(Error::BadRequest("this link has no password".into())),
Err(Error::Unauthorized) => s.db().query_row(
"SELECT pw_hash FROM shares WHERE token = ?1",
[&auth.token],
|r| r.get::<_, String>(0),
)?,
Err(e) => return Err(e),
};
let hash: Option<String> = s
.db()
.query_row(
"SELECT pw_hash FROM shares WHERE token = ?1 AND (expires_at IS NULL OR expires_at > ?2)",
params![b.token, now()],
|r| r.get(0),
)
.optional()?
.ok_or(Error::NotFound)?;
let hash = hash.ok_or_else(|| Error::BadRequest("this link has no password".into()))?;
let (password, h) = (b.password, hash.clone());
auth::limited(&s, ip, &format!("link {}", auth.token), move || {
auth::limited(&s, ip, &format!("link {}", b.token), move || {
auth::verify_password(&password, &h).then_some(())
})
.await?;
Ok(Json(GuestKey {
key: key(&auth.token, &hash),
key: key(&b.token, &hash),
}))
}
Mcrates/server/src/main.rs
@@ -169,9 +169,32 @@ ALTER TABLE shares_new RENAME TO shares;
"
UPDATE shares SET trail_since = CASE WHEN trail = 0 THEN NULL ELSE COALESCE(trail_since, 0) END;
ALTER TABLE shares DROP COLUMN trail;
",
// Credential changes need a recent sign-in. 0: existing sessions must sign in again first.
// Each point stores its cell for every precision in PRECISIONS_M. `fill_cells` computes them for existing points.
"
ALTER TABLE sessions ADD COLUMN signed_in_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE points ADD COLUMN lat_100 REAL;
ALTER TABLE points ADD COLUMN lon_100 REAL;
ALTER TABLE points ADD COLUMN lat_1000 REAL;
ALTER TABLE points ADD COLUMN lon_1000 REAL;
ALTER TABLE points ADD COLUMN lat_10000 REAL;
ALTER TABLE points ADD COLUMN lon_10000 REAL;
ALTER TABLE points ADD COLUMN lat_100000 REAL;
ALTER TABLE points ADD COLUMN lon_100000 REAL;
-- Other precisions round up to the next coarser one, so no share shows more than before.
UPDATE shares SET precision_m = CASE
WHEN precision_m = 0 THEN 0
WHEN precision_m <= 100 THEN 100
WHEN precision_m <= 1000 THEN 1000
WHEN precision_m <= 10000 THEN 10000
ELSE 100000 END;
",
];
/// The migration that adds the cell columns.
const CELLS_MIGRATION: usize = 5;
/// Opens the database and brings its schema up to date.
pub fn open(path: &std::path::Path) -> Result<Connection, Box<dyn std::error::Error>> {
let mut db = Connection::open(path)?;
@@ -194,6 +217,9 @@ fn migrate(db: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
let tx = db.transaction()?;
tx.execute_batch(sql)?;
if i + 1 == CELLS_MIGRATION {
fill_cells(&tx)?;
}
let broken: bool = tx.query_row(
"SELECT EXISTS (SELECT 1 FROM pragma_foreign_key_check)",
[],
@@ -209,6 +235,41 @@ fn migrate(db: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// Computes the cells of every point, in time order per device, as an upload would have.
fn fill_cells(db: &Connection) -> rusqlite::Result<()> {
let devices: Vec<i64> = db
.prepare("SELECT id FROM devices")?
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let mut update = db.prepare(&format!(
"UPDATE points SET ({}) = ({}) WHERE device_id = ?1 AND ts = ?2",
routes::cell_cols(),
(3..3 + 2 * api::PRECISIONS_M.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ")
))?;
for device in devices {
let points: Vec<(i64, f64, f64)> = db
.prepare_cached("SELECT ts, lat, lon FROM points WHERE device_id = ?1 ORDER BY ts")?
.query_map([device], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?;
let mut prev = None;
for (ts, lat, lon) in points {
let cells = routes::cells(prev.as_ref(), lat, lon);
let mut values: Vec<&dyn rusqlite::ToSql> = vec![&device, &ts];
values.extend(
cells
.iter()
.flat_map(|(a, b)| [a as &dyn rusqlite::ToSql, b]),
);
update.execute(values.as_slice())?;
prev = Some(cells);
}
}
Ok(())
}
#[derive(Parser)]
#[command(about = "opentracker server")]
struct Cli {
@@ -319,17 +380,33 @@ pub fn now() -> i64 {
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let db = open(&cli.db).unwrap_or_else(|e| {
let mut db = open(&cli.db).unwrap_or_else(|e| {
eprintln!("cannot open {}: {e}", cli.db.display());
std::process::exit(1);
});
match &cli.command {
Some(Command::Passwd { username }) => passwd(&db, username),
Some(Command::Passwd { username }) => passwd(&mut db, username),
None => serve(cli, db).await,
}
}
pub fn no_users(db: &Connection) -> rusqlite::Result<bool> {
db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))
}
/// Turns a UNIQUE violation into a 409 with `msg`.
pub fn taken(msg: &str) -> impl FnOnce(rusqlite::Error) -> Error + '_ {
move |e| match e {
rusqlite::Error::SqliteFailure(f, _)
if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
{
Error::Conflict(msg.into())
}
e => e.into(),
}
}
/// Inserts a user. The first user ever becomes the admin.
pub fn insert_user(
db: &Connection,
@@ -337,35 +414,32 @@ pub fn insert_user(
pw_hash: &str,
is_admin: bool,
) -> Result<i64, Error> {
let first: bool = db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))?;
let first = no_users(db)?;
db.execute(
"INSERT INTO users (username, pw_hash, is_admin, webauthn_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![username, pw_hash, is_admin || first, uuid::Uuid::new_v4().to_string(), now()],
params![username, pw_hash, is_admin || first, webauthn_rs::prelude::Uuid::new_v4().to_string(), now()],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => {
Error::Conflict("that username is taken".into())
}
e => e.into(),
})?;
.map_err(taken("that username is taken"))?;
Ok(db.last_insert_rowid())
}
/// Sets a password and removes every other way in: passkeys, two-factor sign-in, sessions and device tokens.
/// A reset often follows a lost device, and its passkey or token must not keep working.
pub fn reset_password(db: &Connection, user_id: i64, pw_hash: &str) -> rusqlite::Result<()> {
db.execute(
/// Pairing codes die with the sessions that began them.
pub fn reset_password(db: &mut Connection, user_id: i64, pw_hash: &str) -> rusqlite::Result<()> {
let tx = db.transaction()?;
tx.execute(
"UPDATE users SET pw_hash = ?1, two_factor = 0 WHERE id = ?2",
params![pw_hash, user_id],
)?;
db.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
tx.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
tx.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
// A random hash matches no token. The devices and their history stay, and the owner pairs them again.
db.execute(
tx.execute(
"UPDATE devices SET token_hash = randomblob(32) WHERE user_id = ?1 AND token_hash IS NOT NULL",
[user_id],
)?;
Ok(())
tx.commit()
}
pub fn check_username(name: &str) -> Result<&str, Error> {
@@ -378,7 +452,7 @@ pub fn check_username(name: &str) -> Result<&str, Error> {
Ok(name)
}
fn passwd(db: &Connection, username: &str) {
fn passwd(db: &mut Connection, username: &str) {
let password = std::env::var("OT_PASSWORD").unwrap_or_else(|_| {
// ponytail: the password echoes on the terminal. Use rpassword if that matters.
eprint!("password for {username}: ");
@@ -439,13 +513,7 @@ async fn serve(cli: Cli, db: Connection) {
cli.addr,
cli.web_dir.display()
);
if state
.db()
.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| {
r.get::<_, bool>(0)
})
.unwrap_or(false)
{
if no_users(&state.db()).unwrap_or(false) {
println!("no users yet: open the web UI to create the admin account");
}
let app = routes::router(state, &cli.web_dir);
@@ -491,7 +559,7 @@ pub fn purge_points(
"DELETE FROM points
WHERE device_id IN (SELECT id FROM devices WHERE user_id = ?1) AND ts < ?2
AND ts < (SELECT MAX(ts) FROM points p WHERE p.device_id = points.device_id)",
[user_id, now() - days * 86400],
[user_id, now().saturating_sub(days.saturating_mul(86400))],
),
}
}
@@ -532,6 +600,19 @@ pub fn test_db() -> Connection {
db
}
#[cfg(test)]
pub fn test_state() -> AppState {
AppState {
db: Arc::new(Mutex::new(test_db())),
limiter: Arc::default(),
ceremonies: Arc::default(),
pairings: Arc::default(),
public_url: None,
max_retention_days: 0,
behind_proxy: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -555,7 +636,7 @@ mod tests {
#[test]
fn reset_removes_every_other_way_in() {
let db = test_db();
let mut db = test_db();
let id = insert_user(&db, "a", "old", false).unwrap();
db.execute_batch(
"UPDATE users SET two_factor = 1;
@@ -564,7 +645,7 @@ mod tests {
INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (1, 'phone', x'03', 0), (1, 'Web', NULL, 0);",
)
.unwrap();
reset_password(&db, id, "new").unwrap();
reset_password(&mut db, id, "new").unwrap();
let count = |sql: &str| -> i64 { db.query_row(sql, [], |r| r.get(0)).unwrap() };
assert_eq!(count("SELECT COUNT(*) FROM passkeys"), 0);
assert_eq!(count("SELECT COUNT(*) FROM sessions"), 0);
@@ -622,6 +703,36 @@ mod tests {
assert_eq!(since, [None, Some(0), Some(99)]);
}
#[test]
fn cells_fill_in_time_order_and_precisions_round_up() {
let mut db = Connection::open_in_memory().unwrap();
for sql in &MIGRATIONS[..CELLS_MIGRATION - 1] {
db.execute_batch(sql).unwrap();
}
db.pragma_update(None, "user_version", CELLS_MIGRATION as i64 - 1)
.unwrap();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 20, 0.0049, 0), (1, 10, 0.0044, 0);
INSERT INTO shares (owner_id, viewer_id, created_at, precision_m) VALUES (1, 2, 0, 500);",
)
.unwrap();
migrate(&mut db).unwrap();
let first = routes::cells(None, 0.0044, 0.0);
let second = routes::cells(Some(&first), 0.0049, 0.0);
// The later point sticks to the earlier point's 1 km cell, though alone it would round to the next.
assert_ne!(routes::cells(None, 0.0049, 0.0)[1], second[1]);
let stored: (f64, i64) = db
.query_row(
"SELECT lat_1000, (SELECT precision_m FROM shares) FROM points WHERE ts = 20",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(stored, (second[1].0, 1000));
}
#[test]
fn retention_keeps_each_devices_newest_point() {
let db = test_db();
Mcrates/server/src/passkeys.rs
@@ -17,7 +17,7 @@ use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::*;
use webauthn_rs_proto::ResidentKeyRequirement;
use crate::auth::{self, User};
use crate::auth::{self, ClientIp, User};
use crate::{AppState, Error, now};
pub const PASSKEY_LIMIT: i64 = 10;
@@ -26,6 +26,8 @@ const TTL: Duration = Duration::from_secs(300);
/// Anyone can start a passkey sign-in, so their pending challenges need a cap.
/// Past it the oldest goes, so a flood of starts cannot block everyone else's sign-in.
const MAX_ANONYMOUS: usize = 1000;
/// Passkey sign-in starts per address in 15 minutes, so one client cannot push out the others' challenges.
const MAX_STARTS: u32 = 30;
pub enum Pending {
Register {
@@ -232,7 +234,13 @@ pub fn second_factor(
.into_response())
}
pub async fn login_begin(State(s): State<AppState>, Rp(rp): Rp) -> Result<Json<Challenge>, Error> {
pub async fn login_begin(
State(s): State<AppState>,
ClientIp(ip): ClientIp,
Rp(rp): Rp,
) -> Result<Json<Challenge>, Error> {
let key = format!("passkey {}", auth::ip_group(ip));
s.limiter.attempt(&[(&key, MAX_STARTS)])?;
let (mut options, auth_state) = rp.start_discoverable_authentication().map_err(failed)?;
// Without this the browser waits for the autofill dropdown instead of showing its dialog.
options.mediation = None;
@@ -324,6 +332,7 @@ pub async fn register_begin(
user: User,
Rp(rp): Rp,
) -> Result<Json<Challenge>, Error> {
user.check_recent()?;
let db = s.db();
if count(&db, user.id)? >= PASSKEY_LIMIT {
return Err(too_many());
@@ -394,12 +403,7 @@ pub async fn register_finish(
"INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![user.id, key.cred_id().as_ref(), json, name, created_at],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => {
Error::Conflict("that passkey is already registered".into())
}
e => e.into(),
})?;
.map_err(crate::taken("that passkey is already registered"))?;
let id = db.last_insert_rowid();
drop(db);
auth::end_other_sessions(&s, &user)?;
@@ -416,6 +420,7 @@ pub async fn delete(
user: User,
UrlPath(id): UrlPath<i64>,
) -> Result<Json<()>, Error> {
user.check_recent()?;
let db = s.db();
let (has_password, two_factor): (bool, bool) = db.query_row(
"SELECT pw_hash IS NOT NULL, two_factor FROM users WHERE id = ?1",
Mcrates/server/src/routes.rs
@@ -1,17 +1,19 @@
use std::path::Path;
use api::{
ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_PRECISION_M,
MAX_TRACK_SECS, Me, NewDevice, NewShare, NewUser, Person, PersonDevice, Point, ResetPassword,
ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_TRACK_SECS, Me,
NewDevice, NewShare, NewUser, PRECISIONS_M, Person, PersonDevice, Point, ResetPassword,
SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares, Uploaded,
};
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, Uri, header};
use axum::http::{HeaderMap, HeaderValue, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use rusqlite::{Connection, OptionalExtension, Row, params};
use base64::Engine;
use rusqlite::{Connection, OptionalExtension, Row, ToSql, params};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tower_http::services::ServeDir;
use crate::auth::{self, Admin, ClientIp, User};
@@ -22,6 +24,7 @@ use crate::{device, guest};
type Result<T> = std::result::Result<T, Error>;
pub fn router(state: AppState, web_dir: &Path) -> Router {
let csp = HeaderValue::from_str(&content_security_policy(web_dir)).expect("CSP is ASCII");
Router::new()
.route("/api/setup", get(setup_status).post(setup))
.route("/api/login", post(login))
@@ -65,21 +68,57 @@ pub fn router(state: AppState, web_dir: &Path) -> Router {
.route("/api/guest/unlock", post(guest::unlock))
.route("/healthz", get(healthz))
.fallback_service(ServeDir::new(web_dir))
.layer(axum::middleware::map_response(move |mut res: Response| {
let csp = csp.clone();
async move {
let h = res.headers_mut();
h.insert(header::CONTENT_SECURITY_POLICY, csp);
h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
h.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
// Not no-referrer: the OpenStreetMap tile servers require a Referer.
h.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
res
}
}))
.with_state(state)
}
/// Allows the inline scripts of the built index.html by hash. Trunk names them anew in each build.
fn content_security_policy(web_dir: &Path) -> String {
let html = std::fs::read_to_string(web_dir.join("index.html")).unwrap_or_default();
let hashes: String = html
.split("<script")
.skip(1)
.filter_map(|s| {
let (tag, rest) = s.split_once('>')?;
let body = rest.split_once("</script>")?.0;
let hash = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(body));
(!tag.contains("src=")).then(|| format!(" 'sha256-{hash}'"))
})
.collect();
// Keep the tile hosts in sync with web/src/map.rs.
format!(
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'{hashes}; style-src 'self' 'unsafe-inline'; \
img-src 'self' data: https://tile.openstreetmap.org https://*.tile.openstreetmap.fr \
https://*.tile-cyclosm.openstreetmap.fr https://*.tile.opentopomap.org https://server.arcgisonline.com; \
connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
)
}
async fn healthz(State(s): State<AppState>) -> Result<&'static str> {
s.db().query_row("SELECT 1", [], |_| Ok(()))?;
Ok("ok")
}
fn no_users(db: &Connection) -> rusqlite::Result<bool> {
db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))
}
async fn setup_status(State(s): State<AppState>) -> Result<Json<SetupStatus>> {
Ok(Json(SetupStatus {
needed: no_users(&s.db())?,
needed: crate::no_users(&s.db())?,
}))
}
@@ -89,14 +128,14 @@ async fn setup(State(s): State<AppState>, Json(b): Json<Credentials>) -> Result<
auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
let already = || Error::Conflict("the server is already set up".into());
// Checked before hashing, so a request to a set-up server costs no Argon2 work.
if !no_users(&s.db())? {
if !crate::no_users(&s.db())? {
return Err(already());
}
let hash = auth::hash_password_async(b.password).await?;
let id = {
let db = s.db();
// Checked again under the same lock as the insert, so two setups cannot both win.
if !no_users(&db)? {
if !crate::no_users(&db)? {
return Err(already());
}
crate::insert_user(&db, &username, &hash, true)?
@@ -185,6 +224,8 @@ async fn change_password(
Error::Unauthorized => Error::BadRequest("wrong current password".into()),
e => e,
})?;
} else {
user.check_recent()?;
}
let hash = auth::hash_password_async(b.new).await?;
s.db().execute(
@@ -197,6 +238,7 @@ async fn change_password(
/// Leaves the account on passkeys alone.
async fn delete_password(State(s): State<AppState>, user: User) -> Result<Json<()>> {
user.check_recent()?;
let db = s.db();
if passkeys::count(&db, user.id)? == 0 {
return Err(Error::BadRequest(
@@ -224,6 +266,7 @@ async fn set_two_factor(
user: User,
Json(b): Json<SetTwoFactor>,
) -> Result<Json<()>> {
user.check_recent()?;
let db = s.db();
if b.enabled {
let has_password: bool = db.query_row(
@@ -279,6 +322,43 @@ async fn set_retention(
const POINT_COLS: &str = "ts, lat, lon, acc, alt, speed, bearing, battery";
/// POINT_COLS, with the stored cell of `m` metres in place of the exact position.
fn point_cols(m: u32) -> String {
match m {
0 => POINT_COLS.into(),
m => format!("ts, lat_{m}, lon_{m}, acc, alt, speed, bearing, battery"),
}
}
/// The cell columns of all PRECISIONS_M, in order.
pub fn cell_cols() -> String {
PRECISIONS_M.map(|m| format!("lat_{m}, lon_{m}")).join(", ")
}
pub type Cells = [(f64, f64); PRECISIONS_M.len()];
/// The cells for a new point of a device, given the cells of its previous point.
pub fn cells(prev: Option<&Cells>, lat: f64, lon: f64) -> Cells {
std::array::from_fn(|i| cell(prev.map(|c| c[i]), lat, lon, PRECISIONS_M[i]))
}
/// The centre of a grid cell of about `m` metres. Keeps the previous cell until the point is a quarter cell
/// past its edge. Otherwise GPS noise near an edge flips between two cells and shows where the edge is.
fn cell(prev: Option<(f64, f64)>, lat: f64, lon: f64, m: u32) -> (f64, f64) {
let step = f64::from(m) / 111_320.0;
// A degree of longitude shrinks toward the poles. Using the snapped latitude keeps one grid per row.
let lon_step = |lat: f64| step / lat.to_radians().cos().max(0.01);
if let Some((clat, clon)) = prev
&& (lat - clat).abs() <= 0.75 * step
&& (lon - clon).abs() <= 0.75 * lon_step(clat)
{
return (clat, clon);
}
let clat = ((lat / step).round() * step).clamp(-90.0, 90.0);
let clon = ((lon / lon_step(clat)).round() * lon_step(clat)).clamp(-180.0, 180.0);
(clat, clon)
}
/// Reads the POINT_COLS columns, starting at column `i`.
fn point_at(r: &Row, i: usize) -> rusqlite::Result<Point> {
Ok(Point {
@@ -293,7 +373,7 @@ fn point_at(r: &Row, i: usize) -> rusqlite::Result<Point> {
})
}
/// Snaps a point to a grid of about `m` metres and drops the fields that would reveal more.
/// Drops what would reveal more than a point read with `point_cols(m)` should. The position is already its cell.
fn coarsen(p: &mut Point, m: u32) {
if m == 0 {
return;
@@ -301,15 +381,12 @@ fn coarsen(p: &mut Point, m: u32) {
// A jump to the next cell shows when the owner crossed the cell edge, and where that edge is.
// Rounding the time to m seconds keeps that crossing about m metres vague at walking speed.
p.ts -= p.ts.rem_euclid(i64::from(m));
let step = f64::from(m) / 111_320.0;
p.lat = ((p.lat / step).round() * step).clamp(-90.0, 90.0);
// A degree of longitude shrinks toward the poles. Using the snapped latitude keeps one grid per row.
let lon_step = step / p.lat.to_radians().cos().max(0.01);
p.lon = ((p.lon / lon_step).round() * lon_step).clamp(-180.0, 180.0);
p.acc = Some(p.acc.unwrap_or(0.0).max(m as f32));
p.alt = None;
p.speed = None;
p.bearing = None;
// The battery drains steadily, so it would date a point within its rounded time.
p.battery = None;
}
/// What a viewer may see of one owner.
@@ -360,12 +437,12 @@ fn accesses(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Access>> {
/// The owner's allowed devices with their newest point.
pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> {
let devices = db
let mut devices: Vec<PersonDevice> = db
.prepare_cached(&format!(
"SELECT d.id, d.name, {POINT_COLS} FROM devices d
"SELECT d.id, d.name, {} FROM devices d
JOIN points p ON p.device_id = d.id AND p.ts = (SELECT MAX(ts) FROM points WHERE device_id = d.id)
WHERE d.user_id = ?1 AND {DEVICE_ALLOWED}
ORDER BY p.ts DESC"
WHERE d.user_id = ?1 AND {DEVICE_ALLOWED}",
point_cols(a.precision_m)
))?
.query_map(params![a.owner, a.share, a.all_devices], |r| {
let mut last = point_at(r, 2)?;
@@ -377,6 +454,8 @@ pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> {
})
})?
.collect::<rusqlite::Result<_>>()?;
// By rounded time, so the order does not tell which device sent last within the same rounded time.
devices.sort_by_key(|d| (std::cmp::Reverse(d.last.ts), d.id));
Ok(Person {
id: a.owner,
username: a.username,
@@ -428,10 +507,20 @@ pub fn track_points(
from: i64,
to: i64,
) -> Result<Vec<Point>> {
if to < from || to - from > MAX_TRACK_SECS {
if to
.checked_sub(from)
.is_none_or(|d| !(0..=MAX_TRACK_SECS).contains(&d))
{
return Err(Error::BadRequest("range must be 0 to 31 days".into()));
}
let from = from.max(a.trail_since.ok_or(Error::Forbidden)?);
let since = a.trail_since.ok_or(Error::Forbidden)?;
// Only whole m-second buckets. A bound inside one would split its points by raw time,
// and moving the bound would reveal the raw times that the rounding hides.
// Saturation only matters far from any stored time.
let m = i64::from(a.precision_m.max(1));
let floor = |t: i64| t.saturating_sub(t.rem_euclid(m));
let from = floor(from).max(floor(since.saturating_add(m - 1)));
let to = floor(to).saturating_add(m - 1);
let allowed: bool = db.query_row(
&format!(
"SELECT EXISTS (SELECT 1 FROM devices d WHERE d.id = ?4 AND d.user_id = ?1 AND {DEVICE_ALLOWED})"
@@ -445,8 +534,9 @@ pub fn track_points(
// ponytail: past the limit the oldest points go. Thin the trail evenly if long ranges need all of it.
let mut points: Vec<Point> = db
.prepare_cached(&format!(
"SELECT * FROM (SELECT {POINT_COLS} FROM points WHERE device_id = ?1 AND ts BETWEEN ?2 AND ?3
ORDER BY ts DESC LIMIT {MAX_TRACK_POINTS}) ORDER BY ts"
"SELECT * FROM (SELECT {} FROM points WHERE device_id = ?1 AND ts BETWEEN ?2 AND ?3
ORDER BY ts DESC LIMIT {MAX_TRACK_POINTS}) ORDER BY ts",
point_cols(a.precision_m)
))?
.query_map(params![device, from, to], |r| {
let mut p = point_at(r, 0)?;
@@ -454,6 +544,11 @@ pub fn track_points(
Ok(p)
})?
.collect::<rusqlite::Result<_>>()?;
// The limit can split the oldest bucket, which would show raw times again.
if points.len() as i64 == MAX_TRACK_POINTS && a.precision_m > 0 {
let oldest = points[0].ts;
points.retain(|p| p.ts != oldest);
}
points.dedup_by(|b, a| (a.ts, a.lat, a.lon) == (b.ts, b.lat, b.lon));
Ok(points)
}
@@ -549,30 +644,52 @@ async fn upload(
}
let now = now();
let total = points.len();
let points: Vec<Point> = points
let mut points: Vec<Point> = points
.into_iter()
.filter(|p| check_point(p, now).is_ok())
.collect();
// Each point's cells follow from the previous point's, so older points go first.
points.sort_by_key(|p| p.ts);
let mut db = s.db();
let tx = db.transaction()?;
let mut stored = 0;
{
let cols = cell_cols();
let mut insert = tx.prepare_cached(&format!(
"INSERT OR IGNORE INTO points (device_id, {POINT_COLS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
"INSERT OR IGNORE INTO points (device_id, {POINT_COLS}, {cols}) VALUES ({})",
(1..=9 + 2 * PRECISIONS_M.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ")
))?;
let mut prev = tx.prepare_cached(&format!(
"SELECT {cols} FROM points WHERE device_id = ?1 AND ts < ?2 ORDER BY ts DESC LIMIT 1"
))?;
for p in &points {
stored += insert.execute(params![
uploader.device_id,
p.ts,
p.lat,
p.lon,
p.acc,
p.alt,
p.speed,
p.bearing,
p.battery
])?;
let before: Option<Cells> = prev
.query_row(params![uploader.device_id, p.ts], |r| {
let mut c = [(0.0, 0.0); PRECISIONS_M.len()];
for (i, c) in c.iter_mut().enumerate() {
*c = (r.get(2 * i)?, r.get(2 * i + 1)?);
}
Ok(c)
})
.optional()?;
let cells = cells(before.as_ref(), p.lat, p.lon);
let mut values: Vec<&dyn ToSql> = vec![
&uploader.device_id,
&p.ts,
&p.lat,
&p.lon,
&p.acc,
&p.alt,
&p.speed,
&p.bearing,
&p.battery,
];
values.extend(cells.iter().flat_map(|(a, b)| [a as &dyn ToSql, b]));
stored += insert.execute(values.as_slice())?;
}
}
tx.execute(
@@ -608,10 +725,10 @@ pub fn check_settings(set: &ShareSettings, expires_at: Option<i64>) -> Result<()
if expires_at.is_some_and(|t| t <= now()) {
return Err(Error::BadRequest("expiry must be in the future".into()));
}
if set.precision_m > MAX_PRECISION_M {
return Err(Error::BadRequest(format!(
"precision must be at most {MAX_PRECISION_M} metres"
)));
if set.precision_m != 0 && !PRECISIONS_M.contains(&set.precision_m) {
return Err(Error::BadRequest(
"precision must be 0, 100, 1000, 10000 or 100000 metres".into(),
));
}
if set.devices.as_ref().is_some_and(Vec::is_empty) {
return Err(Error::BadRequest("select at least one device".into()));
@@ -743,9 +860,10 @@ async fn list_users(State(s): State<AppState>, _: Admin) -> Result<Json<Vec<api:
async fn create_user(
State(s): State<AppState>,
_: Admin,
Admin(admin): Admin,
Json(b): Json<NewUser>,
) -> Result<Json<api::User>> {
admin.check_recent()?;
let username = crate::check_username(&b.username)?.to_owned();
auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
let hash = auth::hash_password_async(b.password).await?;
@@ -760,6 +878,7 @@ async fn create_user(
}
/// Admins cannot change their own role, so at least one admin always remains.
/// The statements check that the caller is still an admin, so two admins cannot demote or delete each other at once.
async fn set_role(
State(s): State<AppState>,
Admin(admin): Admin,
@@ -769,9 +888,10 @@ async fn set_role(
if id == admin.id {
return Err(Error::BadRequest("you cannot change your own role".into()));
}
admin.check_recent()?;
if s.db().execute(
"UPDATE users SET is_admin = ?1 WHERE id = ?2",
params![b.is_admin, id],
"UPDATE users SET is_admin = ?1 WHERE id = ?2 AND (SELECT is_admin FROM users WHERE id = ?3)",
params![b.is_admin, id, admin.id],
)? == 0
{
return Err(Error::NotFound);
@@ -789,7 +909,11 @@ async fn delete_user(
"you cannot delete your own account".into(),
));
}
if s.db().execute("DELETE FROM users WHERE id = ?1", [id])? == 0 {
if s.db().execute(
"DELETE FROM users WHERE id = ?1 AND (SELECT is_admin FROM users WHERE id = ?2)",
[id, admin.id],
)? == 0
{
return Err(Error::NotFound);
}
Ok(Json(()))
@@ -798,13 +922,14 @@ async fn delete_user(
/// The recovery path for a user who lost their password or passkey.
async fn reset_user_password(
State(s): State<AppState>,
_: Admin,
Admin(admin): Admin,
UrlPath(id): UrlPath<i64>,
Json(b): Json<ResetPassword>,
) -> Result<Json<()>> {
admin.check_recent()?;
auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
let hash = auth::hash_password_async(b.password).await?;
let db = s.db();
let mut db = s.db();
if db
.query_row("SELECT 1 FROM users WHERE id = ?1", [id], |_| Ok(()))
.optional()?
@@ -812,7 +937,7 @@ async fn reset_user_password(
{
return Err(Error::NotFound);
}
crate::reset_password(&db, id, &hash)?;
crate::reset_password(&mut db, id, &hash)?;
Ok(Json(()))
}
@@ -820,31 +945,24 @@ async fn reset_user_password(
mod tests {
use super::*;
fn pt(ts: i64, lat: f64, lon: f64) -> Point {
Point {
ts,
lat,
lon,
acc: None,
alt: None,
speed: None,
bearing: None,
battery: None,
}
}
#[test]
fn point_validation() {
let now = 1_800_000_000;
assert!(check_point(&pt(now, 48.1, 11.5), now).is_ok());
assert!(check_point(&pt(now, 91.0, 0.0), now).is_err());
assert!(check_point(&pt(now, 0.0, -180.1), now).is_err());
assert!(check_point(&pt(now + 2 * MAX_FUTURE_SECS, 0.0, 0.0), now).is_err());
let p = |ts, lat, lon| Point {
ts,
lat,
lon,
..Default::default()
};
assert!(check_point(&p(now, 48.1, 11.5), now).is_ok());
assert!(check_point(&p(now, 91.0, 0.0), now).is_err());
assert!(check_point(&p(now, 0.0, -180.1), now).is_err());
assert!(check_point(&p(now + 2 * MAX_FUTURE_SECS, 0.0, 0.0), now).is_err());
assert!(
check_point(
&Point {
battery: Some(101),
..pt(now, 0.0, 0.0)
..p(now, 0.0, 0.0)
},
now
)
@@ -853,28 +971,141 @@ mod tests {
}
#[test]
fn coarse_points_stay_near_and_hide_motion() {
fn coarse_points_hide_motion() {
let exact = Point {
ts: 1_800_000_999,
lat: 48.137_15,
lon: 11.575_49,
acc: Some(5.0),
speed: Some(3.0),
..pt(1_800_000_999, 48.137_15, 11.575_49)
battery: Some(80),
..Default::default()
};
let mut p = exact.clone();
coarsen(&mut p, 0);
assert_eq!(p, exact);
coarsen(&mut p, 1000);
assert_eq!(
(p.acc, p.speed, p.battery, p.ts),
(Some(1000.0), None, None, 1_800_000_000)
);
}
#[test]
fn cells_stay_near_and_stick_through_noise() {
let (lat, lon) = (48.137_15, 11.575_49);
let (clat, clon) = cell(None, lat, lon, 1000);
let (dy, dx) = (
(p.lat - exact.lat) * 111_320.0,
(p.lon - exact.lon) * 111_320.0 * exact.lat.to_radians().cos(),
(clat - lat) * 111_320.0,
(clon - lon) * 111_320.0 * lat.to_radians().cos(),
);
assert!(
dy.abs() <= 500.0 && dx.abs() <= 510.0,
"moved {dy} m, {dx} m"
);
assert_eq!((p.acc, p.speed, p.ts), (Some(1000.0), None, 1_800_000_000));
let mut near = pt(1, exact.lat + 0.000_01, exact.lon + 0.000_01);
coarsen(&mut near, 1000);
assert_eq!((near.lat, near.lon), (p.lat, p.lon));
// A point 0.1 m past the south edge, then noise of 10 m around the edge.
let edge = clat - 500.0 / 111_320.0;
let first = cell(None, edge - 0.1 / 111_320.0, clon, 1000);
let mut c = first;
for i in 0..20 {
let noise = if i % 2 == 0 { 10.0 } else { -10.0 };
c = cell(Some(c), edge + noise / 111_320.0, clon, 1000);
assert_eq!(c, first);
}
// Clearly in the next cell.
let moved = cell(Some(c), clat, clon, 1000);
assert_eq!(moved, (clat, clon));
}
fn coarse_db() -> Connection {
let db = crate::test_db();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
INSERT INTO points (device_id, ts, lat, lon, lat_100, lon_100) VALUES
(1, 1000, 0, 0, 0, 0), (1, 1042, 1, 1, 1, 1), (1, 1099, 2, 2, 2, 2), (1, 1100, 3, 3, 3, 3);",
)
.unwrap();
db
}
fn coarse(trail_since: i64) -> Access {
Access {
owner: 1,
username: "a".into(),
share: None,
all_devices: true,
trail_since: Some(trail_since),
precision_m: 100,
}
}
#[test]
fn coarse_tracks_cover_whole_buckets_only() {
let db = coarse_db();
let lats = |a: &Access, from, to| -> Vec<f64> {
track_points(&db, a, 1, from, to)
.unwrap()
.iter()
.map(|p| p.lat)
.collect()
};
// Any bound inside a bucket gives the whole bucket, so it cannot split 1042 from 1099.
for to in [1000, 1041, 1042, 1099] {
assert_eq!(lats(&coarse(0), 1000, to), [0.0, 1.0, 2.0], "to {to}");
}
for from in [1001, 1042, 1043, 1099] {
assert_eq!(lats(&coarse(0), from, 1099), [0.0, 1.0, 2.0], "from {from}");
}
// A trail start inside a bucket leaves out the whole bucket.
assert_eq!(lats(&coarse(1042), 0, 2000), [3.0]);
}
#[test]
fn huge_ranges_are_refused() {
let db = coarse_db();
assert!(matches!(
track_points(&db, &coarse(0), 1, i64::MIN, i64::MAX),
Err(Error::BadRequest(_))
));
assert!(track_points(&db, &coarse(i64::MIN), 1, i64::MAX - 10, i64::MAX).is_ok());
}
async fn admin(s: &AppState, id: i64) -> Result<Json<()>> {
let caller = Admin(User {
id,
username: String::new(),
is_admin: true,
session_hash: vec![],
signed_in_at: now(),
});
set_role(
State(s.clone()),
caller,
UrlPath(3 - id),
Json(SetRole { is_admin: false }),
)
.await
}
#[tokio::test]
async fn two_admins_cannot_demote_each_other() {
let s = crate::test_state();
s.db()
.execute_batch(
"INSERT INTO users (id, username, webauthn_id, is_admin, created_at) VALUES (1, 'a', '1', 1, 0), (2, 'b', '2', 1, 0);",
)
.unwrap();
assert!(admin(&s, 1).await.is_ok());
// User 2's request passed the extractor before user 1 demoted them.
assert!(admin(&s, 2).await.is_err());
let admins: i64 = s
.db()
.query_row("SELECT COUNT(*) FROM users WHERE is_admin", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(admins, 1);
}
#[test]
Mweb/src/i18n.rs
@@ -1,6 +1,6 @@
//! German texts. English is the source language and the key: `tr("Log out")`.
//! A `{}` in a key matches any text, so texts with values translate too, also the server's messages.
//! A German text uses `{}` in the same order, or `{0}`, `{1}` to reorder.
//! A German text uses `{}` in the same order.
use std::sync::OnceLock;
@@ -52,9 +52,6 @@ fn matches<'a>(key: &str, text: &'a str) -> Option<Vec<&'a str>> {
fn fill(de: &str, values: &[&str]) -> String {
let mut out = de.to_owned();
for (i, v) in values.iter().enumerate() {
out = out.replace(&format!("{{{i}}}"), v);
}
for v in values {
out = out.replacen("{}", v, 1);
}
@@ -90,6 +87,7 @@ const DE: &[(&str, &str)] = &[
("Add passkey", "Passkey hinzufügen"),
("Added", "Hinzugefügt"),
("Added \"{}\".", "\"{}\" hinzugefügt."),
("Address", "Adresse"),
("Admin", "Admin"),
(
"All devices, also ones I add later",
@@ -362,6 +360,7 @@ const DE: &[(&str, &str)] = &[
"Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.",
),
("Topographic", "Topografisch"),
("Token", "Token"),
("Trail", "Spur"),
(
"Two-factor sign-in needs a password and at least one passkey.",
@@ -532,8 +531,12 @@ const DE: &[(&str, &str)] = &[
"höchstens {} Punkte pro Anfrage",
),
(
"precision must be at most {} metres",
"die Genauigkeit darf höchstens {} Meter betragen",
"precision must be 0, 100, 1000, 10000 or 100000 metres",
"die Genauigkeit muss 0, 100, 1000, 10000 oder 100000 Meter sein",
),
(
"log out and log in again before you change this",
"melde dich ab und wieder an, bevor du das änderst",
),
(
"you can have at most {} passkeys",
@@ -574,19 +577,13 @@ mod tests {
);
assert_eq!(matches("{} points.", "12 points!"), None);
assert_eq!(matches("Log out", "Log out"), Some(vec![]));
assert_eq!(fill("{1}, dann {0}", &["a", "b"]), "b, dann a");
assert_eq!(fill("{} Punkte", &["3"]), "3 Punkte");
}
#[test]
fn german_texts_keep_every_value() {
for (en, de) in DE {
let values = en.matches("{}").count();
let used = de.matches("{}").count()
+ (0..values)
.filter(|i| de.contains(&format!("{{{i}}}")))
.count();
assert_eq!(values, used, "{en}");
assert_eq!(en.matches("{}").count(), de.matches("{}").count(), "{en}");
}
}
}
Mweb/src/settings.rs
@@ -80,6 +80,32 @@ fn status(message: RwSignal<Option<Result<String, String>>>) -> impl IntoView {
}
}
/// A table with one row per item, an error, or the `empty` hint.
fn table<T, V>(
items: LocalResource<Result<Vec<T>, http::Error>>,
empty: &'static str,
columns: [&'static str; 3],
row: impl Fn(T) -> V + Copy + Send + Sync + 'static,
) -> impl IntoView
where
T: Clone + 'static,
V: IntoView + 'static,
{
move || {
items.get().map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr(empty)}</p> }.into_any(),
Ok(list) => view! {
<table>
<tr>{columns.map(|c| view! { <th>{tr(c)}</th> })}<th></th></tr>
{list.into_iter().map(row).collect_view()}
</table>
}
.into_any(),
})
}
}
#[component]
fn Devices() -> impl IntoView {
let account = expect_context::<Account>();
@@ -147,7 +173,8 @@ fn Devices() -> impl IntoView {
.map(|token| {
view! {
<p>{tr("The token is shown only once. Set up the device with:")}</p>
<p><code class="copy">{format!("ot use-token {token_url} {token}")}</code></p>
<p>{tr("Address")} " " <code class="copy">{token_url.clone()}</code></p>
<p>{tr("Token")} " " <code class="copy">{token}</code></p>
}
})
}}
@@ -156,38 +183,17 @@ fn Devices() -> impl IntoView {
</section>
<section>
<h2>{tr("Devices")}</h2>
{move || {
devices
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr("No devices yet.")}</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>{tr("Name")}</th><th>{tr("Added")}</th><th>{tr("Last upload")}</th><th></th></tr>
{list
.into_iter()
.map(|d| {
view! {
<tr>
<td>
{d.name.clone()}
{d.web.then(|| view! { <div class="hint">{tr("The web app, in any browser")}</div> })}
</td>
<td>{fmt_time(d.created_at)}</td>
<td>{d.last_seen_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove(&d)>{tr("Remove")}</button></td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
{table(devices, "No devices yet.", ["Name", "Added", "Last upload"], move |d| view! {
<tr>
<td>
{d.name.clone()}
{d.web.then(|| view! { <div class="hint">{tr("The web app, in any browser")}</div> })}
</td>
<td>{fmt_time(d.created_at)}</td>
<td>{d.last_seen_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove(&d)>{tr("Remove")}</button></td>
</tr>
})}
</section>
}
}
@@ -583,52 +589,32 @@ fn Links() -> impl IntoView {
</section>
<section>
<h2>{tr("Guest links")}</h2>
{move || {
links
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr("No links yet.")}</p> }.into_any(),
Ok(list) => {
let mine = devices.get();
view! {
<table>
<tr><th>{tr("Name")}</th><th>{tr("Shows")}</th><th>{tr("Expires")}</th><th></th></tr>
{list
.into_iter()
.map(|l| {
let link = url(&l.token);
let mut shows = describe(&l.settings, Some(&mine));
if l.has_password {
shows = format!("{shows} · {}", tr("password"));
}
view! {
<tr>
<td>{if l.name.is_empty() { tr("unnamed") } else { l.name.clone() }}</td>
<td>{shows}</td>
<td>{expiry(l.expires_at)}</td>
<td class="actions">
<button on:click=move |_| {
let link = link.clone();
spawn_local(async move {
if copy(link).await {
message.set(Some(Ok(tr("Link copied."))));
}
});
}>{tr("Copy")}</button>
" "
<button on:click=move |_| remove(&l)>{tr("Delete")}</button>
</td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
{table(links, "No links yet.", ["Name", "Shows", "Expires"], move |l| {
let link = url(&l.token);
let mut shows = devices.with(|mine| describe(&l.settings, Some(mine)));
if l.has_password {
shows = format!("{shows} · {}", tr("password"));
}
view! {
<tr>
<td>{if l.name.is_empty() { tr("unnamed") } else { l.name.clone() }}</td>
<td>{shows}</td>
<td>{expiry(l.expires_at)}</td>
<td class="actions">
<button on:click=move |_| {
let link = link.clone();
spawn_local(async move {
if copy(link).await {
message.set(Some(Ok(tr("Link copied."))));
}
});
}>{tr("Copy")}</button>
" "
<button on:click=move |_| remove(&l)>{tr("Delete")}</button>
</td>
</tr>
}
})}
</section>
}
}
@@ -852,35 +838,14 @@ fn Security() -> impl IntoView {
</section>
<section>
<h2>{tr("Passkeys")}</h2>
{move || {
passkeys
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr("No passkeys yet.")}</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>{tr("Name")}</th><th>{tr("Added")}</th><th>{tr("Last used")}</th><th></th></tr>
{list
.into_iter()
.map(|k| {
view! {
<tr>
<td>{k.name.clone()}</td>
<td>{fmt_time(k.created_at)}</td>
<td>{k.last_used_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove_passkey(&k)>{tr("Remove")}</button></td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
{table(passkeys, "No passkeys yet.", ["Name", "Added", "Last used"], move |k| view! {
<tr>
<td>{k.name.clone()}</td>
<td>{fmt_time(k.created_at)}</td>
<td>{k.last_used_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove_passkey(&k)>{tr("Remove")}</button></td>
</tr>
})}
<Show
when=passkey::supported
fallback=|| view! { <p class="hint">"This browser does not support passkeys."</p> }
@@ -1015,50 +980,33 @@ fn Users() -> impl IntoView {
</section>
<section>
<h2>{tr("Users")}</h2>
{move || {
users
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>{tr("Username")}</th><th>{tr("Role")}</th><th>{tr("Created")}</th><th></th></tr>
{list
.into_iter()
.map(|u| {
let me = Some(u.id) == my_id;
let u2 = u.clone();
let u3 = u.clone();
view! {
<tr>
<td>{u.username.clone()}</td>
<td>
<select
aria-label=tr("Role")
disabled=me
on:change:target=move |ev| set_role(&u3, ev.target().value() == "admin")
>
<option value="user" selected=!u.is_admin>{tr("user")}</option>
<option value="admin" selected=u.is_admin>{tr("admin")}</option>
</select>
</td>
<td>{fmt_time(u.created_at)}</td>
<td class="actions">
<button on:click=move |_| reset(&u2)>{tr("Reset password")}</button>
" "
<button disabled=me on:click=move |_| remove(&u)>{tr("Delete")}</button>
</td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
// The list always contains the admin who views it.
{table(users, "", ["Username", "Role", "Created"], move |u| {
let me = Some(u.id) == my_id;
let u2 = u.clone();
let u3 = u.clone();
view! {
<tr>
<td>{u.username.clone()}</td>
<td>
<select
aria-label=tr("Role")
disabled=me
on:change:target=move |ev| set_role(&u3, ev.target().value() == "admin")
>
<option value="user" selected=!u.is_admin>{tr("user")}</option>
<option value="admin" selected=u.is_admin>{tr("admin")}</option>
</select>
</td>
<td>{fmt_time(u.created_at)}</td>
<td class="actions">
<button on:click=move |_| reset(&u2)>{tr("Reset password")}</button>
" "
<button disabled=me on:click=move |_| remove(&u)>{tr("Delete")}</button>
</td>
</tr>
}
})}
</section>
}
}