From 9da643b23e7e4457df9fcd98e36e1cb40889b0e3 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 27 Sep 2026 09:13:47 +0800 Subject: [PATCH] web: bind with --host and --port --- README.md | 30 ++++++++- src/http/server.rs | 15 +++-- src/main.rs | 161 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 173 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 87b517a..3301ba0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ Environment variables: | Variable | Effect | |----------|--------| -| `APSIS_WEB_ADDRESS` | Bind address, default `127.0.0.1:8080` | +| `APSIS_WEB_HOST` | Interface to bind, default `127.0.0.1` | +| `APSIS_WEB_PORT` | Port to bind, default `8080` | +| `APSIS_WEB_ADDRESS` | Bind address as `HOST:PORT`, overriding the two above | | `APSIS_WEB_WORKERS` | Worker threads, default 4 | | `APSIS_WEB_SKIP_FRONTEND` | Set to `1` to skip the frontend build in a release build | | `APSIS_WEB_FRONTEND_DIR` | Frontend directory, default `frontend` | @@ -64,9 +66,30 @@ Environment variables: Command line options override the environment: ```sh -apsis-web --address 0.0.0.0:8080 --workers 8 +# Bind every interface on port 8080. +apsis-web --host 0.0.0.0 --port 8080 + +# The combined form is equivalent. +apsis-web --address 0.0.0.0:8080 ``` +`--host` and `--port` may be given together. `--address` cannot be combined +with either of them, because the order in which they would apply is not +defined. A host may be a literal address, an IPv6 literal in brackets, or a +name the operating system resolves. + +### Language + +The interface is bilingual and opens in Chinese. The reader switches to +English with the control in the top bar; the choice is stored in the browser +and applies to every page. + +The backend carries every user facing string in both languages, so the switch +takes effect without a request. A concept document is written once in English +as `content/concepts/.md` and translated as +`content/concepts/.zh.md`. A document without a translation is shown in +English to every reader, with a note saying so. + ## Running ```sh @@ -109,8 +132,9 @@ or later. The Rust build does not run it and does not depend on Python. | Path | Contents | |------|----------| -| `content/concepts/chapters.json` | Chapter list and the reading order of documents | +| `content/concepts/chapters.json` | Chapter list and the reading order of documents, with bilingual titles | | `content/concepts/*.md` | Concept documents, with a `key: value` metadata header | +| `content/concepts/*.zh.md` | Chinese translations; the identifier comes from the file name | | `content/graph/concepts.json` | The concept graph | | `content/flows/*.json` | The algorithm flow graphs | | `content/api.json` | The generated interface catalog; do not edit by hand | diff --git a/src/http/server.rs b/src/http/server.rs index 094e47c..8d7d070 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -60,11 +60,12 @@ pub struct Server { } impl Server { - /// Binds a server to `address`. + /// Binds a server to a socket address. /// - /// The address accepts anything that resolves to a socket address, such - /// as `"127.0.0.1:8080"` or `"0.0.0.0:80"`. - pub fn bind(address: &str, config: ServerConfig) -> io::Result { + /// This is the form the command line uses once the host and the port + /// have been resolved, so that a name is resolved once rather than left + /// to the listener. + pub fn bind_address(address: SocketAddr, config: ServerConfig) -> io::Result { let listener = TcpListener::bind(address)?; Ok(Self { listener, config }) } @@ -198,8 +199,10 @@ mod tests { #[test] fn serves_a_request_over_a_real_socket() { - let server = Server::bind( - "127.0.0.1:0", + let server = Server::bind_address( + "127.0.0.1:0" + .parse() + .expect("the literal address must parse"), ServerConfig { workers: 2, ..ServerConfig::default() diff --git a/src/main.rs b/src/main.rs index 4cb3a5c..06ae4df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,16 +12,21 @@ //! //! Because every asset is embedded, the binary is self-contained: it serves //! the same content from a copy that has no source tree next to it. +//! +//! The `--host` and `--port` options bind the listener, and the language of +//! the user interface is selected in the browser and defaults to Chinese. mod api; mod docs; mod embedded; mod graph; mod http; +mod i18n; mod json; mod state; mod tools; +use std::net::SocketAddr; use std::process::ExitCode; use std::sync::Arc; @@ -29,8 +34,12 @@ use crate::api::Router; use crate::http::{Server, ServerConfig}; use crate::state::AppState; -/// Default bind address. -const DEFAULT_ADDRESS: &str = "127.0.0.1:8080"; +/// Default bind host, which accepts connections from the local machine +/// only. +const DEFAULT_HOST: &str = "127.0.0.1"; + +/// Default bind port. +const DEFAULT_PORT: u16 = 8080; /// Entry point. fn main() -> ExitCode { @@ -49,7 +58,7 @@ fn main() -> ExitCode { ..ServerConfig::default() }; - let server = match Server::bind(&options.address, config) { + let server = match Server::bind_address(options.address, config) { Ok(server) => server, Err(error) => { eprintln!("apsis-web: cannot bind {}: {error}", options.address); @@ -60,7 +69,7 @@ fn main() -> ExitCode { let bound = server .local_address() .map(|address| address.to_string()) - .unwrap_or_else(|_| options.address.clone()); + .unwrap_or_else(|_| options.address.to_string()); println!("apsis-web listening on http://{bound}"); if !embedded::is_populated() { println!("apsis-web: no frontend build is embedded; serving the placeholder page"); @@ -77,8 +86,8 @@ fn main() -> ExitCode { /// Command line options, read from arguments and then from the environment. struct Options { - /// Address to bind, in `host:port` form. - address: String, + /// Address to bind. + address: SocketAddr, /// Number of worker threads. workers: usize, } @@ -86,36 +95,67 @@ struct Options { impl Options { /// Builds the options from the process arguments. /// - /// Arguments override the environment variables `APSIS_WEB_ADDRESS` and - /// `APSIS_WEB_WORKERS`. `--help` and `--version` print and exit. + /// Arguments override the environment variables `APSIS_WEB_HOST`, + /// `APSIS_WEB_PORT`, `APSIS_WEB_ADDRESS`, and `APSIS_WEB_WORKERS`. + /// `--help` and `--version` print and exit. + /// + /// The host and the port may be given separately with `--host` and + /// `--port`, or together with `--address`. Giving both `--address` and + /// either of the two separate options is an error, because the order in + /// which they would apply is not defined. fn from_environment() -> Result { - let mut options = Self { - address: std::env::var("APSIS_WEB_ADDRESS") - .unwrap_or_else(|_| DEFAULT_ADDRESS.to_string()), - workers: match std::env::var("APSIS_WEB_WORKERS") { - Ok(value) => value - .parse() - .map_err(|_| "APSIS_WEB_WORKERS must be a positive integer".to_string())?, - Err(_) => 4, - }, + let mut host = std::env::var("APSIS_WEB_HOST").unwrap_or_else(|_| DEFAULT_HOST.to_string()); + let mut port = match std::env::var("APSIS_WEB_PORT") { + Ok(value) => parse_port(&value).map_err(|error| format!("APSIS_WEB_PORT: {error}"))?, + Err(_) => DEFAULT_PORT, }; - if options.workers == 0 { + let mut combined: Option = match std::env::var("APSIS_WEB_ADDRESS") { + Ok(value) => { + Some(parse_address(&value).map_err(|error| format!("APSIS_WEB_ADDRESS: {error}"))?) + } + Err(_) => None, + }; + let mut workers = match std::env::var("APSIS_WEB_WORKERS") { + Ok(value) => value + .parse() + .map_err(|_| "APSIS_WEB_WORKERS must be a positive integer".to_string())?, + Err(_) => 4, + }; + if workers == 0 { return Err("APSIS_WEB_WORKERS must be greater than zero".to_string()); } + let mut host_given = false; + let mut port_given = false; + let mut arguments = std::env::args().skip(1); while let Some(argument) = arguments.next() { match argument.as_str() { + "--host" => { + host = arguments + .next() + .ok_or_else(|| "--host needs a value".to_string())?; + host_given = true; + } + "--port" | "-p" => { + let value = arguments + .next() + .ok_or_else(|| "--port needs a value".to_string())?; + port = parse_port(&value).map_err(|error| format!("--port: {error}"))?; + port_given = true; + } "--address" | "-a" => { - options.address = arguments + let value = arguments .next() .ok_or_else(|| "--address needs a value".to_string())?; + combined = + Some(parse_address(&value).map_err(|error| format!("--address: {error}"))?); } "--workers" | "-w" => { let value = arguments .next() .ok_or_else(|| "--workers needs a value".to_string())?; - options.workers = value + workers = value .parse() .map_err(|_| "--workers needs a positive integer".to_string())?; } @@ -130,10 +170,75 @@ impl Options { other => return Err(format!("unknown argument {other:?}; try --help")), } } - if options.workers == 0 { + + if workers == 0 { return Err("--workers must be greater than zero".to_string()); } - Ok(options) + + let address = if host_given || port_given { + if combined.is_some() { + return Err( + "--address cannot be combined with --host or --port; use one form".to_string(), + ); + } + resolve(&host, port).map_err(|error| format!("--host: {error}"))? + } else if let Some(address) = combined { + address + } else { + resolve(&host, port).map_err(|error| format!("APSIS_WEB_HOST: {error}"))? + }; + + Ok(Self { address, workers }) + } +} + +/// Parses a port number. +fn parse_port(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| format!("{value:?} is not a port number")) +} + +/// Parses an address written as `host:port`. +fn parse_address(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| format!("{value:?} is not a host and port")) +} + +/// Resolves a host and a port into a socket address. +/// +/// A host may be a literal address, a bracketed or bare IPv6 literal, or a +/// name that the operating system resolves. A name that resolves to several +/// addresses uses the first one, which is the order the resolver reports and +/// therefore the preferred one. +fn resolve(host: &str, port: u16) -> Result { + let host = host.trim(); + if host.is_empty() { + return Err("the host is empty".to_string()); + } + + // A literal address, including a bracketed IPv6 literal, needs no name + // resolution. + let literal = host.trim_start_matches('[').trim_end_matches(']'); + if let Ok(ip) = literal.parse::() { + return Ok(SocketAddr::new(ip, port)); + } + + // A name that already carries a port, such as `localhost:9000`, is + // accepted so that a value written in the combined form still works. + if let Ok(address) = host.parse::() { + return Ok(address); + } + + use std::net::ToSocketAddrs; + match (host, port).to_socket_addrs() { + Ok(mut addresses) => addresses + .next() + .ok_or_else(|| format!("{host:?} does not resolve to an address")), + Err(error) => Err(format!("{host:?} does not resolve: {error}")), } } @@ -146,10 +251,18 @@ USAGE: OPTIONS: -a, --address address to bind (default 127.0.0.1:8080) + --host interface to bind (default 127.0.0.1) + -p, --port port to bind (default 8080) -w, --workers worker threads (default 4) -h, --help print this help -V, --version print the version ENVIRONMENT: - APSIS_WEB_ADDRESS bind address - APSIS_WEB_WORKERS worker threads"; + APSIS_WEB_ADDRESS bind address, as HOST:PORT + APSIS_WEB_HOST interface to bind + APSIS_WEB_PORT port to bind + APSIS_WEB_WORKERS worker threads + +EXAMPLES: + apsis-web --host 0.0.0.0 --port 8080 + apsis-web --address 127.0.0.1:9000";