main.rs
⎇
Raw
1//! `ot`: a test client for the opentracker API.
2
3use std::path::PathBuf;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use api::{DeviceToken, MAX_BATCH, Person, Point, RegisterDevice, Uploaded};
7use serde::{Deserialize, Serialize};
8
9const USAGE: &str = "usage: ot [--http3] [--insecure] <command>
10 login <url> <user> [device-name] register this machine as a device
11 (password from OT_PASSWORD or stdin)
12 use-token <url> <token> use a device token created in the web UI
13 send <lat> <lon> upload one point
14 simulate [--interval S] [--batch N] [lat lon]
15 random walk, one point every S seconds (default 2),
16 uploaded in batches of N (default 5)
17 people show the positions you can see
18
19--http3 use HTTP/3 only. Needs an HTTPS reverse proxy that speaks it.
20--insecure accept any TLS certificate, for a local proxy with its own CA.
21config file: $OT_CONFIG, default ~/.config/ot/config.json";
22
23#[derive(Serialize, Deserialize)]
24struct Config {
25 url: String,
26 token: String,
27}
28
29fn config_path() -> PathBuf {
30 if let Ok(p) = std::env::var("OT_CONFIG") {
31 return p.into();
32 }
33 let base = std::env::var("XDG_CONFIG_HOME")
34 .map(PathBuf::from)
35 .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").expect("HOME")).join(".config"));
36 base.join("ot/config.json")
37}
38
39fn load_config() -> Config {
40 let path = config_path();
41 let text = std::fs::read_to_string(&path).unwrap_or_else(|_| {
42 fail(&format!(
43 "no config at {}. Run `ot login` first.",
44 path.display()
45 ))
46 });
47 serde_json::from_str(&text).expect("valid config file")
48}
49
50fn save_config(c: &Config) {
51 use std::os::unix::fs::OpenOptionsExt;
52 let path = config_path();
53 std::fs::create_dir_all(path.parent().unwrap()).expect("create config dir");
54 let file = std::fs::OpenOptions::new()
55 .write(true)
56 .create(true)
57 .truncate(true)
58 .mode(0o600)
59 .open(&path)
60 .expect("write config");
61 serde_json::to_writer_pretty(file, c).expect("write config");
62 println!("saved {}", path.display());
63}
64
65fn fail(msg: &str) -> ! {
66 eprintln!("{msg}");
67 std::process::exit(1);
68}
69
70fn now() -> i64 {
71 SystemTime::now()
72 .duration_since(UNIX_EPOCH)
73 .unwrap()
74 .as_secs() as i64
75}
76
77/// Removes `--name value` from `args` and returns the value.
78fn take_opt(args: &mut Vec<String>, name: &str) -> Option<String> {
79 let i = args.iter().position(|a| a == name)?;
80 if i + 1 >= args.len() {
81 fail(&format!("{name} needs a value"));
82 }
83 args.remove(i);
84 Some(args.remove(i))
85}
86
87fn take_flag(args: &mut Vec<String>, name: &str) -> bool {
88 let found = args.iter().any(|a| a == name);
89 args.retain(|a| a != name);
90 found
91}
92
93fn num<T: std::str::FromStr>(s: &str) -> T {
94 s.parse()
95 .unwrap_or_else(|_| fail(&format!("not a number: {s}")))
96}
97
98struct Http {
99 client: reqwest::Client,
100 http3: bool,
101}
102
103impl Http {
104 fn post(&self, url: String) -> reqwest::RequestBuilder {
105 let req = self.client.post(url);
106 // reqwest picks HTTP/3 per request, not per client.
107 if self.http3 {
108 req.version(reqwest::Version::HTTP_3)
109 } else {
110 req
111 }
112 }
113}
114
115#[tokio::main(flavor = "current_thread")]
116async fn main() {
117 let mut args: Vec<String> = std::env::args().skip(1).collect();
118 let http3 = take_flag(&mut args, "--http3");
119 let insecure = take_flag(&mut args, "--insecure");
120 let mut builder = reqwest::Client::builder()
121 .timeout(Duration::from_secs(15))
122 .danger_accept_invalid_certs(insecure);
123 if http3 {
124 builder = builder.http3_prior_knowledge();
125 }
126 let client = Http {
127 client: builder.build().expect("HTTP client"),
128 http3,
129 };
130
131 let interval = take_opt(&mut args, "--interval").map_or(2.0, |s| num::<f64>(&s));
132 let batch = take_opt(&mut args, "--batch").map_or(5, |s| num::<usize>(&s));
133 let args: Vec<&str> = args.iter().map(String::as_str).collect();
134 match args[..] {
135 ["login", url, user] => login(&client, url, user, &default_device_name()).await,
136 ["login", url, user, name] => login(&client, url, user, name).await,
137 ["use-token", url, token] => save_config(&Config {
138 url: url.trim_end_matches('/').into(),
139 token: token.into(),
140 }),
141 ["send", lat, lon] => {
142 let p = Point {
143 ts: now(),
144 ..point(num(lat), num(lon))
145 };
146 let up = upload(&client, &load_config(), &[p])
147 .await
148 .unwrap_or_else(|e| fail(&e));
149 println!("stored {}", up.stored);
150 }
151 ["simulate"] => simulate(&client, 48.1372, 11.5754, interval, batch).await,
152 ["simulate", lat, lon] => simulate(&client, num(lat), num(lon), interval, batch).await,
153 ["people"] => {
154 let up = upload(&client, &load_config(), &[])
155 .await
156 .unwrap_or_else(|e| fail(&e));
157 print_people(&up.people);
158 }
159 _ => fail(USAGE),
160 }
161}
162
163fn default_device_name() -> String {
164 std::fs::read_to_string("/etc/hostname")
165 .map(|s| s.trim().to_owned())
166 .ok()
167 .filter(|s| !s.is_empty())
168 .unwrap_or_else(|| "cli".into())
169}
170
171async fn login(client: &Http, url: &str, username: &str, name: &str) {
172 let password = std::env::var("OT_PASSWORD").unwrap_or_else(|_| {
173 // ponytail: the password echoes on the terminal. Use rpassword if that matters.
174 eprint!("password for {username}: ");
175 let mut line = String::new();
176 std::io::stdin()
177 .read_line(&mut line)
178 .expect("read password");
179 line.trim_end_matches(['\r', '\n']).to_owned()
180 });
181 let url = url.trim_end_matches('/');
182 let body = RegisterDevice {
183 username: username.into(),
184 password,
185 name: name.into(),
186 };
187 let res = client
188 .post(format!("{url}/api/devices/register"))
189 .json(&body)
190 .send()
191 .await
192 .unwrap_or_else(|e| fail(&format!("{e:?}")));
193 let res = check(res).await.unwrap_or_else(|e| fail(&e));
194 let DeviceToken { token } = res.json().await.expect("token response");
195 save_config(&Config {
196 url: url.into(),
197 token,
198 });
199}
200
201async fn check(res: reqwest::Response) -> Result<reqwest::Response, String> {
202 if res.status().is_success() {
203 return Ok(res);
204 }
205 let status = res.status();
206 let body = res.text().await.unwrap_or_default();
207 Err(format!("{status} {body}"))
208}
209
210/// An empty upload is valid. It returns the visible people without storing anything.
211async fn upload(client: &Http, cfg: &Config, points: &[Point]) -> Result<Uploaded, String> {
212 let res = client
213 .post(format!("{}/api/points", cfg.url))
214 .bearer_auth(&cfg.token)
215 .json(points)
216 .send()
217 .await
218 .map_err(|e| format!("{e:?}"))?;
219 let res = check(res).await?;
220 eprintln!("[{:?}] uploaded {} point(s)", res.version(), points.len());
221 res.json().await.map_err(|e| e.to_string())
222}
223
224fn point(lat: f64, lon: f64) -> Point {
225 Point {
226 ts: 0,
227 lat,
228 lon,
229 acc: None,
230 alt: None,
231 speed: None,
232 bearing: None,
233 battery: None,
234 }
235}
236
237fn print_people(people: &[Person]) {
238 for p in people {
239 match p.last() {
240 Some(d) => {
241 let l = &d.last;
242 println!(
243 "{:<16} {:<12} {:>10.6} {:>11.6} {:>5}s ago acc {:>4} bat {:>3}",
244 p.username,
245 d.name,
246 l.lat,
247 l.lon,
248 now() - l.ts,
249 l.acc.map_or("-".into(), |a| format!("{a:.0}m")),
250 l.battery.map_or("-".into(), |b| format!("{b}%")),
251 )
252 }
253 None => println!("{:<16} no position yet", p.username),
254 }
255 }
256}
257
258/// Uniform in [0, 1).
259fn rand01() -> f64 {
260 getrandom::u32().expect("OS random number generator") as f64 / (u32::MAX as f64 + 1.0)
261}
262
263async fn simulate(client: &Http, mut lat: f64, mut lon: f64, interval: f64, batch: usize) {
264 let cfg = load_config();
265 let speed = 8.0; // m/s, fast enough to see movement on the map
266 let mut heading = rand01() * 360.0;
267 let mut pending: Vec<Point> = Vec::new();
268 let start = now();
269 let mut tick = tokio::time::interval(Duration::from_secs_f64(interval));
270 loop {
271 tick.tick().await;
272 heading = (heading + (rand01() - 0.5) * 40.0).rem_euclid(360.0);
273 let dist = speed * interval;
274 lat += dist * heading.to_radians().cos() / 111_320.0;
275 lon += dist * heading.to_radians().sin() / (111_320.0 * lat.to_radians().cos());
276 pending.push(Point {
277 ts: now(),
278 acc: Some(5.0 + rand01() as f32 * 10.0),
279 speed: Some(speed as f32),
280 bearing: Some(heading as f32),
281 battery: Some((100 - (now() - start) / 60 % 100) as u8),
282 ..point(lat, lon)
283 });
284 // Several points can share one second when the interval is short. The server keeps the first.
285 pending.dedup_by_key(|p| p.ts);
286 if pending.len() < batch {
287 continue;
288 }
289 match upload(client, &cfg, &pending).await {
290 Ok(_) => pending.clear(),
291 Err(e) => {
292 eprintln!("upload failed, keeping {} point(s): {e}", pending.len());
293 if pending.len() > MAX_BATCH {
294 pending.drain(..pending.len() - MAX_BATCH);
295 }
296 }
297 }
298 }
299}
300