Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/cli-rpc-host-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Changed

- `rbitcoin-cli --rpc-url` takes `host:port` (plain HTTP). A scheme, path, or
userinfo is rejected, including `https://`, which used to be stripped and
then dialed without TLS. A numeric host must be a strict IP. Leading zeros,
short quads, and hex forms are rejected so the bearer token is not sent to
the address `getaddrinfo` would rewrite them into.
155 changes: 132 additions & 23 deletions crates/rbitcoin-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use rbitcoin_primitives::Network;
use serde_json::Value;
use std::ffi::OsString;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
Expand All @@ -17,7 +17,7 @@ fn usage() -> String {
--datadir PATH node datadir (default ./datadir); unix socket PATH/rpc.sock\n\
--rpc-socket PATH unix socket the node binds with --rpc-socket\n\
--network NET mainnet|testnet|signet|regtest (default TCP port)\n\
--rpc-url URL HTTP JSON-RPC (default http://127.0.0.1:<network port>)\n\
--rpc-url ADDR plain HTTP host:port (else 127.0.0.1:<network port> if no socket)\n\
--rpc-token-file PATH Bearer token (default PATH/rpc.token from --datadir)\n\
-h, --help this message\n\
-V, --version print version\n\
Expand Down Expand Up @@ -133,23 +133,85 @@ fn token_path(cfg: &CliConfig) -> PathBuf {
.unwrap_or_else(|| cfg.datadir.join("rpc.token"))
}

fn parse_http_url(url: &str) -> Result<(String, u16), String> {
let rest = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.unwrap_or(url);
let (host, port) = rest
.rsplit_once(':')
.ok_or_else(|| format!("--rpc-url needs host:port (got {url})"))?;
let port: u16 = port
.parse()
.map_err(|_| format!("invalid --rpc-url port in {url}"))?;
if host.is_empty() {
return Err(format!("invalid --rpc-url host in {url}"));
fn parse_rpc_addr(addr: &str) -> Result<(String, u16), String> {
let bad = || format!("--rpc-url takes host:port (plain HTTP, no scheme; got {addr})");
if addr.contains("://") || addr.contains('/') || addr.contains('@') {
return Err(bad());
}
let (raw_host, port_s) = addr.rsplit_once(':').ok_or_else(bad)?;
let host = if let Some(inner) = raw_host.strip_prefix('[') {
let inner = inner.strip_suffix(']').ok_or_else(bad)?;
if !inner.contains(':') {
return Err(bad());
}
inner
} else if raw_host.contains(':') {
return Err(bad());
} else {
raw_host

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] An unbracketed host is returned unchanged and later dialed with TcpStream::connect((host, port)) (lib.rs:286), which falls through to getaddrinfo. On this libc that rewrites inet_aton forms that IpAddr and the node's SocketAddr (config.rs rpc_listen → val.parse()) both reject. Verified resolutions: 010.0.0.1:8332 → 8.0.0.1:8332, 0127.0.0.1:8332 → 87.0.0.1:8332, 127.0.0.010:8332 → 127.0.0.8:8332, 0x7f000001:8332 and 2130706433:8332 → 127.0.0.1:8332, 127.1:8332 → 127.0.0.1:8332. dispatch_call reads rpc.token and writes Authorization: Bearer only after that connect succeeds (lib.rs:290-291), so a padded or hex literal that looks local sends the bearer to another host. https://, userinfo, a path, and a query do not take this path (they error first). Real names such as rpc.lan and scoped IPv6 such as [fe80::1%lo]:8332 still resolve to the intended address; those are not this bug.

Suggestion: If the host is only an IP literal, dial IpAddr::from_str (Rust rejects leading zeros, short quads, hex, and decimal dwords) and do not call ToSocketAddrs. Reject leftover numeric forms (010.0.0.1, 0x7f000001, 2130706433, 127.1) with the existing host:port error. Keep getaddrinfo for DNS names, and keep scoped IPv6 on the SocketAddr / getaddrinfo path so [fe80::1%1] and [fe80::1%lo] still work. Cover 010.0.0.1:8332 and 0x7f000001:8332 in rpc_url_is_host_and_port as errors.

};
if host.is_empty() || !acceptable_host(host) {
return Err(bad());
}
let port: u16 = port_s
.parse()
.map_err(|_| format!("invalid --rpc-url port in {addr}"))?;
Ok((host.to_string(), port))
}

/// A strict IP, a scoped IPv6 address, or a DNS name. inet_aton spellings
/// (`010.0.0.1`, `0x7f.0.0.1`) are rejected so libc does not retarget them.
fn acceptable_host(host: &str) -> bool {
if let Some((addr, zone)) = host.split_once('%') {
return addr.parse::<Ipv6Addr>().is_ok() && valid_zone(zone);
}
host.parse::<IpAddr>().is_ok() || !inet_aton_literal(host)
}

fn valid_zone(zone: &str) -> bool {
!zone.is_empty()
&& !zone.contains('%')
&& zone
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
}

fn inet_aton_literal(host: &str) -> bool {
host.split('.').all(inet_aton_label)
}

fn inet_aton_label(label: &str) -> bool {
if label.is_empty() {
return false;
}
if label.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
let Some(hex) = label
.strip_prefix("0x")
.or_else(|| label.strip_prefix("0X"))
else {
return false;
};
!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())
}

fn connect_tcp(host: &str, port: u16) -> Result<TcpStream, String> {
let connect = match host.parse::<IpAddr>() {
Ok(ip) => TcpStream::connect(SocketAddr::new(ip, port)),
Err(_) => TcpStream::connect((host, port)),
};
connect.map_err(|e| format!("connect {host}:{port}: {e}"))
}

fn http_host_value(host: &str, port: u16) -> String {
if host.contains(':') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}

fn read_token(path: &Path) -> Result<String, String> {
let line =
std::fs::read_to_string(path).map_err(|e| format!("read token {}: {e}", path.display()))?;
Expand Down Expand Up @@ -183,9 +245,10 @@ fn rpc_http(
Some(t) => format!("Authorization: Bearer {t}\r\n"),
None => String::new(),
};
let host_hdr = http_host_value(host, port);
let req = format!(
"POST / HTTP/1.1\r\n\
Host: {host}:{port}\r\n\
Host: {host_hdr}\r\n\
{auth_h}\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Expand Down Expand Up @@ -262,11 +325,10 @@ fn dispatch_call(cfg: &CliConfig) -> Result<String, String> {
rpc_http(stream, "localhost", 0, None, cmd, &params)?
} else {
let (host, port) = match cfg.rpc_url.as_deref() {
Some(u) => parse_http_url(u)?,
Some(u) => parse_rpc_addr(u)?,
None => ("127.0.0.1".into(), cfg.network.default_rpc_port()),
};
let stream = TcpStream::connect((host.as_str(), port))
.map_err(|e| format!("connect {host}:{port}: {e}"))?;
let stream = connect_tcp(&host, port)?;
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
stream.set_write_timeout(Some(Duration::from_secs(30))).ok();
let token = read_token(&token_path(cfg))?;
Expand Down Expand Up @@ -375,6 +437,53 @@ mod tests {
(port, h)
}

#[test]
fn rpc_url_is_host_and_port() {
assert_eq!(
parse_rpc_addr("127.0.0.1:8332").unwrap(),
("127.0.0.1".into(), 8332)
);
assert_eq!(
parse_rpc_addr("rpc.lan:18443").unwrap(),
("rpc.lan".into(), 18443)
);
assert_eq!(parse_rpc_addr("[::1]:8332").unwrap(), ("::1".into(), 8332));
assert_eq!(
parse_rpc_addr("[fe80::1%lo]:8332").unwrap(),
("fe80::1%lo".into(), 8332)
);
assert_eq!(
parse_rpc_addr("dead.beef:8332").unwrap(),
("dead.beef".into(), 8332)
);
assert_eq!(
parse_rpc_addr("0x10.lan:8332").unwrap(),
("0x10.lan".into(), 8332)
);
assert_eq!(http_host_value("127.0.0.1", 8332), "127.0.0.1:8332");
assert_eq!(http_host_value("::1", 8332), "[::1]:8332");
for bad in [
"http://127.0.0.1:8332",
"https://127.0.0.1:8332",
"127.0.0.1:8332/",
"user:pass@127.0.0.1:8332",
"::1:8332",
"127.0.0.1",
"010.0.0.1:8332",
"0x7f.0.0.1:8332",
"0x7f000001:8332",
"127.1:8332",
"2130706433:8332",
"[fe80::1%]:8332",
] {
let err = parse_rpc_addr(bad).unwrap_err();
assert!(err.contains("host:port"), "{bad} -> {err}");
}
let u = usage();
assert!(u.contains("--rpc-url ADDR"), "{u}");
assert!(!u.contains("http://"), "{u}");
}

#[test]
fn param_tokens_json_or_string() {
assert_eq!(param_value("0"), serde_json::json!(0));
Expand All @@ -391,7 +500,7 @@ mod tests {
let code = cli_main([
"rbitcoin-cli",
flag.as_str(),
&format!("--rpc-url=http://127.0.0.1:{port}"),
&format!("--rpc-url=127.0.0.1:{port}"),
"getblockcount",
]);
let _ = std::fs::remove_dir_all(&dir);
Expand All @@ -405,7 +514,7 @@ mod tests {
for needle in [
"--datadir PATH",
"--network NET",
"--rpc-url URL",
"--rpc-url ADDR",
"--rpc-token-file PATH",
"-h, --help",
"-V, --version",
Expand All @@ -432,7 +541,7 @@ mod tests {
"--datadir",
dir.to_str().unwrap(),
"--rpc-url",
&format!("http://127.0.0.1:{port}"),
&format!("127.0.0.1:{port}"),
"getblockcount",
]);
let _ = std::fs::remove_dir_all(&dir);
Expand All @@ -452,7 +561,7 @@ mod tests {
"--datadir",
dir.to_str().unwrap(),
"--rpc-url",
&format!("http://127.0.0.1:{port}"),
&format!("127.0.0.1:{port}"),
"getblockcount",
]);
let _ = std::fs::remove_dir_all(&dir);
Expand Down
5 changes: 3 additions & 2 deletions docs/lightning.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ Wrapper [`scripts/lightning/bitcoin-cli`](../scripts/lightning/bitcoin-cli) talk
| `getutxout` | `gettxout` | Live coin: `value` BTC, `scriptPubKey.hex`. Spent / unknown / disconnected archive: JSON **`null`** (not RPC error). |
| `sendrawtransaction` | `sendrawtransaction` | `maxfeerate` is **sat/vB** (default 10000). CLN `allowhighfees` must pass **`0`**. |

`rbitcoin-cli` argv is `--datadir` / `--rpc-url`, not Core `-datadir=` /
`-rpcport`. That is why the wrapper exists.
`rbitcoin-cli` argv is `--datadir` / `--rpc-url host:port`, not Core
`-datadir=` / `-rpcport`. The value is an address, not a URL. That is why
the wrapper exists.

## LDK / ldk-node

Expand Down
10 changes: 6 additions & 4 deletions docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,15 @@ curl -H "Authorization: Bearer $TOKEN" --data-binary \

`--datadir` (default `./datadir`) prefers `{datadir}/rpc.sock`.
`--rpc-socket PATH` talks to a node started with `--rpc-socket PATH`. TCP uses
`--rpc-url` (default `http://127.0.0.1:<network port>`) and Bearer from
`{datadir}/rpc.token` or `--rpc-token-file`. Prints the JSON-RPC `result`
(strings unquoted).
`--rpc-url host:port` (plain HTTP; no scheme). An IP is a strict address
(`10.0.0.1`, `[::1]`); leading zeros and hex forms are rejected. When the flag is omitted and no
socket is present, the client uses `127.0.0.1` and the network RPC port. Bearer
comes from `{datadir}/rpc.token` or `--rpc-token-file`. Prints the JSON-RPC
`result` (strings unquoted).

```bash
rbitcoin-cli --datadir datadir getblockcount
rbitcoin-cli --network regtest --rpc-url http://127.0.0.1:18443 getblockchaininfo
rbitcoin-cli --network regtest --rpc-url 127.0.0.1:18443 getblockchaininfo
```

## shindex matrix
Expand Down
Loading