web: bind with --host and --port

This commit is contained in:
dev 2026-09-27 09:13:47 +08:00
parent cf1bca9492
commit ff704fa72d
3 changed files with 173 additions and 33 deletions

View File

@ -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/<id>.md` and translated as
`content/concepts/<id>.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 |

View File

@ -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<Self> {
/// 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<Self> {
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()

View File

@ -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<Self, String> {
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") {
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,
};
let mut combined: Option<SocketAddr> = 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 options.workers == 0 {
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<u16, String> {
value
.trim()
.parse::<u16>()
.map_err(|_| format!("{value:?} is not a port number"))
}
/// Parses an address written as `host:port`.
fn parse_address(value: &str) -> Result<SocketAddr, String> {
value
.trim()
.parse::<SocketAddr>()
.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<SocketAddr, String> {
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::<std::net::IpAddr>() {
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::<SocketAddr>() {
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 <HOST:PORT> address to bind (default 127.0.0.1:8080)
--host <HOST> interface to bind (default 127.0.0.1)
-p, --port <PORT> port to bind (default 8080)
-w, --workers <N> 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";