12.6 KiB, 31 runs
created by r2848102244:147, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! `ore-relay` -- the always-on half of a sync. |
| 2 | //! |
| 3 | //! A separate program rather than a verb of `ore`, and deliberately so. `ore` has |
| 4 | //! ten verbs and that is the budget; more to the point, a relay is a different |
| 5 | //! thing from a working copy -- it has no files, authors nothing, and is run by |
| 6 | //! whoever owns the machine rather than by whoever is writing. |
| 7 | //! |
| 8 | //! ```text |
| 9 | //! ore-relay serve <data> [--port <n>] [--host <addr>] [--reply-bytes <n>] |
| 10 | //! [--post-bytes <n>] [--reading-bytes <n>] |
| 11 | //! ore-relay create <account>/<name> --owner <key> [--public] |
| 12 | //! ore-relay grant <account>/<name> <key> <pull|push|admin> |
| 13 | //! ore-relay list |
| 14 | //! ``` |
| 15 | //! |
| 16 | //! A key is written the way `ore init` and `ore key` print it. Creation is an |
| 17 | //! administrator's act at this rung: a client that pushes to a repository nobody |
| 18 | //! made is told so rather than making one. |
| 19 | |
| 20 | use ore_relay::acl::{ |
| 21 | Acl, |
| 22 | Role, |
| 23 | }; |
| 24 | use ore_relay::host::Host; |
| 25 | use ore_relay::serve; |
| 26 | |
| 27 | use ore_store::keys::{ |
| 28 | public_of, |
| 29 | text_of, |
| 30 | }; |
| 31 | |
| 32 | use oxedyne_fe2o3_core::prelude::*; |
| 33 | |
| 34 | use std::env; |
| 35 | use std::str::FromStr; |
| 36 | use std::net::{ |
| 37 | IpAddr, |
| 38 | SocketAddr, |
| 39 | }; |
| 40 | use std::path::PathBuf; |
| 41 | |
| 42 | |
| 43 | /// What the program prints when it is asked for nothing it knows. |
| 44 | const USAGE: &str = "\ |
| 45 | ore-relay -- a machine that holds Ore histories and authors none |
| 46 | |
| 47 | usage: |
| 48 | ore-relay serve <data-dir> [--host <addr>] [--port <n>] [--log <level>] |
| 49 | [--reply-bytes <n>] [--post-bytes <n>] [--reading-bytes <n>] |
| 50 | answer syncs for the repositories under <data-dir> |
| 51 | ore-relay create <account>/<name> --owner <key> [--public] |
| 52 | make an empty hosted repository |
| 53 | ore-relay grant <account>/<name> <key> <pull|push|admin> |
| 54 | let a key read or write one |
| 55 | ore-relay list <data-dir> |
| 56 | say what this relay holds |
| 57 | |
| 58 | create and grant take the data directory from --data, or the working directory. |
| 59 | |
| 60 | The relay is never an authority. It verifies nothing on the way in, because a |
| 61 | signature it could check is one the puller must check anyway, and it carries key |
| 62 | bindings without being able to mint them: a binding is signed by the key it |
| 63 | binds, so a fabricated one fails its own signature. A relay that fails, lies or |
| 64 | withholds delays convergence exactly as a partition would, and can corrupt no |
| 65 | history and block no local work."; |
| 66 | |
| 67 | |
| 68 | /// Sets how much of the library's own logging is printed. |
| 69 | /// |
| 70 | /// A relay is a server and a server watching itself is useful, but the default |
| 71 | /// is to trace every byte of every exchange, which drowns the lines this program |
| 72 | /// writes about what it is holding. Errors only, unless somebody asks for more. |
| 73 | fn logging(level: &str) |
| 74 | -> Outcome<()> |
| 75 | { |
| 76 | let mut cfg = log_get_config!(); |
| 77 | cfg.file = None; |
| 78 | log_set_config!(cfg); |
| 79 | log_set_level!(fmt!("{}", level)); |
| 80 | Ok(()) |
| 81 | } |
| 82 | |
| 83 | /// Runs the subcommand the arguments name. |
| 84 | fn run(args: &[String]) |
| 85 | -> Outcome<()> |
| 86 | { |
| 87 | let verb = match args.first() { |
| 88 | Some(v) => v.as_str(), |
| 89 | None => { |
| 90 | println!("{}", USAGE); |
| 91 | return Ok(()); |
| 92 | }, |
| 93 | }; |
| 94 | let rest = &args[1..]; |
| 95 | match verb { |
| 96 | "serve" => serve_at(rest), |
| 97 | "create" => create(rest), |
| 98 | "grant" => grant(rest), |
| 99 | "list" => list(rest), |
| 100 | "help" | "-h" | "--help" => { |
| 101 | println!("{}", USAGE); |
| 102 | Ok(()) |
| 103 | }, |
| 104 | other => Err(err!( |
| 105 | "There is no command {:?}. There are four: serve, create, grant, list.", |
| 106 | other; |
| 107 | Invalid, Input)), |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Returns the value of a named option, where it was given. |
| 112 | fn option(args: &[String], name: &str) |
| 113 | -> Outcome<Option<String>> |
| 114 | { |
| 115 | let mut at = 0usize; |
| 116 | while at < args.len() { |
| 117 | if args[at] == name { |
| 118 | return match args.get(at + 1) { |
| 119 | Some(v) => Ok(Some(v.clone())), |
| 120 | None => Err(err!( |
| 121 | "{} needs a value after it.", name; |
| 122 | Invalid, Input, Missing)), |
| 123 | }; |
| 124 | } |
| 125 | at += 1; |
| 126 | } |
| 127 | Ok(None) |
| 128 | } |
| 129 | |
| 130 | /// Returns the positional arguments, which are those not part of an option. |
| 131 | fn plain(args: &[String], taking: &[&str]) -> Vec<String> { |
| 132 | let mut out = Vec::new(); |
| 133 | let mut at = 0usize; |
| 134 | while at < args.len() { |
| 135 | let arg = &args[at]; |
| 136 | if taking.contains(&arg.as_str()) { |
| 137 | at += 2; |
| 138 | continue; |
| 139 | } |
| 140 | if arg.starts_with("--") { |
| 141 | at += 1; |
| 142 | continue; |
| 143 | } |
| 144 | out.push(arg.clone()); |
| 145 | at += 1; |
| 146 | } |
| 147 | out |
| 148 | } |
| 149 | |
| 150 | /// Returns the data directory a command was pointed at. |
| 151 | fn data_of(args: &[String], positional: Option<&String>) |
| 152 | -> Outcome<PathBuf> |
| 153 | { |
| 154 | if let Some(named) = res!(option(args, "--data")) { |
| 155 | return Ok(PathBuf::from(named)); |
| 156 | } |
| 157 | match positional { |
| 158 | Some(p) => Ok(PathBuf::from(p)), |
| 159 | None => match env::current_dir() { |
| 160 | Ok(d) => Ok(d), |
| 161 | Err(e) => Err(err!(e, |
| 162 | "The current directory could not be read."; |
| 163 | IO, File, Read)), |
| 164 | }, |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// Splits `<account>/<name>` into its two labels. |
| 169 | fn label_of(said: &str) |
| 170 | -> Outcome<(String, String)> |
| 171 | { |
| 172 | let parts: Vec<&str> = said.split('/').collect(); |
| 173 | if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { |
| 174 | return Err(err!( |
| 175 | "A repository is named <account>/<name>, and {:?} is not.", said; |
| 176 | Invalid, Input)); |
| 177 | } |
| 178 | Ok((fmt!("{}", parts[0]), fmt!("{}", parts[1]))) |
| 179 | } |
| 180 | |
| 181 | /// `ore-relay serve` -- answers syncs until it is stopped. |
| 182 | fn serve_at(args: &[String]) |
| 183 | -> Outcome<()> |
| 184 | { |
| 185 | let positional = plain(args, &[ |
| 186 | "--host", "--port", "--data", "--log", "--reply-bytes", "--post-bytes", |
| 187 | "--reading-bytes", |
| 188 | ]); |
| 189 | let dir = res!(data_of(args, positional.first())); |
| 190 | if let Some(level) = res!(option(args, "--log")) { |
| 191 | res!(logging(&level)); |
| 192 | } |
| 193 | let host = match res!(option(args, "--host")) { |
| 194 | Some(h) => h, |
| 195 | None => fmt!("127.0.0.1"), |
| 196 | }; |
| 197 | let port = match res!(option(args, "--port")) { |
| 198 | Some(p) => match p.parse::<u16>() { |
| 199 | Ok(n) => n, |
| 200 | Err(e) => return Err(err!(e, |
| 201 | "The port {:?} is not a number between 0 and 65535.", p; |
| 202 | Invalid, Input)), |
| 203 | }, |
| 204 | None => 8420, |
| 205 | }; |
| 206 | let ip = match host.parse::<IpAddr>() { |
| 207 | Ok(a) => a, |
| 208 | Err(e) => return Err(err!(e, |
| 209 | "The address {:?} is not one this relay can bind.", host; |
| 210 | Invalid, Input)), |
| 211 | }; |
| 212 | let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { |
| 213 | Ok(r) => r, |
| 214 | Err(e) => return Err(err!(e, |
| 215 | "The relay could not start a runtime."; |
| 216 | IO, Init)), |
| 217 | }; |
| 218 | // What one reply may carry. An operator serving callers with less memory than |
| 219 | // this relay has may lower it; what it costs a caller is further visits, and |
| 220 | // what it saves the caller is materialising a whole clone as one body. |
| 221 | // |
| 222 | // Not a way to make a clone cheap on the receiving end. Measured 2026-08-20, |
| 223 | // bounding a 58 MB clone into fourteen replies moved the receiving peak by |
| 224 | // under one percent, because that peak is the per-operation cost of holding the |
| 225 | // history rather than the body. |
| 226 | // |
| 227 | // It is a way to make one cheap on this end, which it was not until 2026-08-23: |
| 228 | // the owed turn was built whole and truncated after, so a lower bound cost this |
| 229 | // relay more work rather than less. `serve::outgoing` stops at the bound now, |
| 230 | // and a clone of fe2o3's history at six mebibytes went from 2.48 s of this |
| 231 | // relay's processor to 0.96 s. |
| 232 | let mut host = Host::at(&dir); |
| 233 | if let Some(b) = res!(option(args, "--reply-bytes")) { |
| 234 | host = match b.parse::<usize>() { |
| 235 | Ok(n) if n > 0 => host.with_reply_bytes(n), |
| 236 | _ => return Err(err!( |
| 237 | "The reply bound {:?} is not a positive number of bytes.", b; |
| 238 | Invalid, Input)), |
| 239 | }; |
| 240 | } |
| 241 | // What one REQUEST may carry, which is the number a proxy in front of this |
| 242 | // relay decides and this relay could only guess at until now. It is published |
| 243 | // on `GET /ore` and beside the bindings, a client posts the smaller of it and |
| 244 | // its own ceiling, and a body over it is refused here with a sentence rather |
| 245 | // than by the proxy with a closed connection. Set it to what the proxy takes, |
| 246 | // less room for the headers. |
| 247 | if let Some(b) = res!(option(args, "--post-bytes")) { |
| 248 | host = match b.parse::<usize>() { |
| 249 | Ok(n) if n > 0 => host.with_post_bytes(n), |
| 250 | _ => return Err(err!( |
| 251 | "The request bound {:?} is not a positive number of bytes.", b; |
| 252 | Invalid, Input)), |
| 253 | }; |
| 254 | } |
| 255 | // What the logs this relay is holding may come to. It holds each hosted |
| 256 | // repository as it last read it and extends that reading over whatever has been |
| 257 | // appended since, so that serving a read costs the bytes since the last request |
| 258 | // rather than the whole history: measured on a copy of fe2o3, a thirty-two |
| 259 | // request clone went from 2,801 MB of reads and 16.2 s of this relay's processor |
| 260 | // to 1.6 MB and 2.5 s. |
| 261 | // |
| 262 | // Lower it on a host with less memory than `reading::READING_BUDGET` assumes; |
| 263 | // zero holds nothing, which is this relay as it stood before, reading the whole |
| 264 | // history on every request. |
| 265 | if let Some(b) = res!(option(args, "--reading-bytes")) { |
| 266 | host = match b.parse::<u64>() { |
| 267 | Ok(n) => host.with_reading_bytes(n), |
| 268 | Err(e) => return Err(err!(e, |
| 269 | "The reading bound {:?} is not a number of bytes.", b; |
| 270 | Invalid, Input)), |
| 271 | }; |
| 272 | } |
| 273 | runtime.block_on(serve::listen(host, SocketAddr::new(ip, port))) |
| 274 | } |
| 275 | |
| 276 | /// `ore-relay create` -- makes an empty hosted repository. |
| 277 | fn create(args: &[String]) |
| 278 | -> Outcome<()> |
| 279 | { |
| 280 | let positional = plain(args, &["--owner", "--data"]); |
| 281 | let said = match positional.first() { |
| 282 | Some(s) => s, |
| 283 | None => return Err(err!( |
| 284 | "`ore-relay create` needs one argument: the repository, as \ |
| 285 | <account>/<name>."; |
| 286 | Invalid, Input, Missing)), |
| 287 | }; |
| 288 | let (account, name) = res!(label_of(said)); |
| 289 | let owner = match res!(option(args, "--owner")) { |
| 290 | Some(k) => res!(public_of(&k)), |
| 291 | None => return Err(err!( |
| 292 | "`ore-relay create` needs --owner, the public key of whoever owns the \ |
| 293 | repository. `ore init` and `ore key` print it."; |
| 294 | Invalid, Input, Missing)), |
| 295 | }; |
| 296 | let public = args.iter().any(|a| a == "--public"); |
| 297 | let dir = res!(data_of(args, positional.get(1))); |
| 298 | let host = Host::at(&dir); |
| 299 | let hosted = res!(host.create(&account, &name, &Acl::new(owner.clone(), public))); |
| 300 | println!("created {} at {}", hosted.label, hosted.dir.display()); |
| 301 | println!("owner {}", text_of(&owner)); |
| 302 | println!("access {}", if public { |
| 303 | "anyone may pull; the owner and whoever is granted may push" |
| 304 | } else { |
| 305 | "the owner, and whoever is granted" |
| 306 | }); |
| 307 | Ok(()) |
| 308 | } |
| 309 | |
| 310 | /// `ore-relay grant` -- lets a key read or write a hosted repository. |
| 311 | fn grant(args: &[String]) |
| 312 | -> Outcome<()> |
| 313 | { |
| 314 | let positional = plain(args, &["--data"]); |
| 315 | if positional.len() < 3 { |
| 316 | return Err(err!( |
| 317 | "`ore-relay grant` needs three arguments: the repository as \ |
| 318 | <account>/<name>, the public key, and the role."; |
| 319 | Invalid, Input, Missing)); |
| 320 | } |
| 321 | let (account, name) = res!(label_of(&positional[0])); |
| 322 | let key = res!(public_of(&positional[1])); |
| 323 | let role = res!(Role::of(positional[2].trim())); |
| 324 | let dir = res!(data_of(args, positional.get(3))); |
| 325 | let host = Host::at(&dir); |
| 326 | let hosted = match res!(host.open(&account, &name)) { |
| 327 | Some(h) => h, |
| 328 | None => return Err(err!( |
| 329 | "This relay does not hold {}/{}.", account, name; |
| 330 | Invalid, Input, Missing)), |
| 331 | }; |
| 332 | let mut acl = hosted.acl; |
| 333 | acl.grant(key.clone(), role); |
| 334 | res!(acl.write(&hosted.dir)); |
| 335 | println!("granted {} on {} to {}", role.name(), hosted.label, text_of(&key)); |
| 336 | Ok(()) |
| 337 | } |
| 338 | |
| 339 | /// `ore-relay list` -- says what this relay holds. |
| 340 | fn list(args: &[String]) |
| 341 | -> Outcome<()> |
| 342 | { |
| 343 | let positional = plain(args, &["--data"]); |
| 344 | let dir = res!(data_of(args, positional.first())); |
| 345 | let host = Host::at(&dir); |
| 346 | let accounts = match std::fs::read_dir(&host.dir) { |
| 347 | Ok(e) => e, |
| 348 | Err(e) => return Err(err!(e, |
| 349 | "The data directory {:?} could not be read.", host.dir; |
| 350 | IO, File, Read)), |
| 351 | }; |
| 352 | let mut found = 0usize; |
| 353 | let mut names: Vec<String> = Vec::new(); |
| 354 | for account in accounts { |
| 355 | let account = res!(account); |
| 356 | if !res!(account.file_type()).is_dir() { |
| 357 | continue; |
| 358 | } |
| 359 | let label = account.file_name().to_string_lossy().into_owned(); |
| 360 | for repo in res!(std::fs::read_dir(account.path())) { |
| 361 | let repo = res!(repo); |
| 362 | if !res!(repo.file_type()).is_dir() { |
| 363 | continue; |
| 364 | } |
| 365 | names.push(fmt!("{}/{}", label, repo.file_name().to_string_lossy())); |
| 366 | } |
| 367 | } |
| 368 | names.sort(); |
| 369 | for name in &names { |
| 370 | let (account, repo) = res!(label_of(name)); |
| 371 | match res!(host.open(&account, &repo)) { |
| 372 | Some(hosted) => { |
| 373 | let replayed = res!(hosted.store.replay( |
| 374 | ore_store::store::Verify::Nothing, |
| 375 | ore_store::store::Keep::Nothing, |
| 376 | )); |
| 377 | println!("{:<32} {} operation{}, {} binding{}", |
| 378 | hosted.label, |
| 379 | replayed.log.len(), |
| 380 | if replayed.log.len() == 1 { "" } else { "s" }, |
| 381 | res!(hosted.bindings()).len(), |
| 382 | if res!(hosted.bindings()).len() == 1 { "" } else { "s" }, |
| 383 | ); |
| 384 | found += 1; |
| 385 | }, |
| 386 | None => (), |
| 387 | } |
| 388 | } |
| 389 | if found == 0 { |
| 390 | println!("this relay holds nothing yet; `ore-relay create` makes a repository"); |
| 391 | } |
| 392 | Ok(()) |
| 393 | } |
| 394 | |
| 395 | /// Runs the command and reports a failure on standard error. |
| 396 | fn main() { |
| 397 | let args: Vec<String> = env::args().skip(1).collect(); |
| 398 | if let Err(e) = logging("error") { |
| 399 | eprintln!("ore-relay: {}", e.plain()); |
| 400 | std::process::exit(1); |
| 401 | } |
| 402 | match run(&args) { |
| 403 | Ok(()) => (), |
| 404 | Err(e) => { |
| 405 | eprintln!("ore-relay: {}", e.plain()); |
| 406 | std::process::exit(1); |
| 407 | }, |
| 408 | } |
| 409 | } |