Skip to content

Commit ed415a0

Browse files
achamayouCopilotCopilotCopilot
authored
Experimental support for IPv6 (#7671)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 66f247c commit ed415a0

28 files changed

Lines changed: 693 additions & 84 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ jobs:
113113
]
114114
container:
115115
image: mcr.microsoft.com/azurelinux/base/core:3.0
116-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE
116+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
117117

118118
steps:
119119
- name: "Checkout dependencies"
@@ -187,7 +187,7 @@ jobs:
187187
]
188188
container:
189189
image: mcr.microsoft.com/azurelinux/base/core:3.0
190-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE
190+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
191191

192192
steps:
193193
- name: "Checkout dependencies"

.github/workflows/coverage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
]
2222
container:
2323
image: mcr.microsoft.com/azurelinux/base/core:3.0
24-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE
24+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
2525

2626
steps:
2727
- name: "Checkout dependencies"

.github/workflows/long-test.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
]
3030
container:
3131
image: mcr.microsoft.com/azurelinux/base/core:3.0
32-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE
32+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
3333

3434
steps:
3535
- name: "Checkout dependencies"
@@ -89,7 +89,7 @@ jobs:
8989
]
9090
container:
9191
image: mcr.microsoft.com/azurelinux/base/core:3.0
92-
options: --user root --publish-all --privileged
92+
options: --user root --publish-all --privileged --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
9393

9494
steps:
9595
- name: "Checkout dependencies"
@@ -152,7 +152,7 @@ jobs:
152152
]
153153
container:
154154
image: mcr.microsoft.com/azurelinux/base/core:3.0
155-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE
155+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
156156

157157
steps:
158158
- name: "Checkout dependencies"
@@ -224,7 +224,7 @@ jobs:
224224
]
225225
container:
226226
image: mcr.microsoft.com/azurelinux/base/core:3.0
227-
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE
227+
options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0
228228

229229
steps:
230230
- name: "Checkout dependencies"

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ jobs:
104104
]
105105
container:
106106
image: ${{ needs.image_digest.outputs.image_digest }}
107-
options: "--user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE"
107+
options: "--user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.default.disable_ipv6=0 --sysctl net.ipv6.conf.lo.disable_ipv6=0"
108108

109109
steps:
110110
- name: "Set SOURCE_DATE_EPOCH"

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
[7.0.6]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.6
1111

12+
### Added
13+
14+
- Experimental support for IPv6. Node RPC and node-to-node interface hosts may now be specified as IPv6 literals in bracketed form (e.g. `[::1]:8000`), and addresses are consistently parsed, bound, connected (with fallback across mixed IPv4/IPv6 resolved addresses), serialised, and embedded in redirect URLs for IPv6 (#7671).
15+
1216
### Fixed
1317

1418
- Forwarded commands are no longer processed until the node is part of the network, matching the existing behaviour for other node-to-node messages. Previously a forwarded command could be executed while the node was in an earlier startup state, which could lead to undefined behaviour for some commands (#7936).

doc/host_config_schema/host_config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"description": "The published node address advertised to other nodes. This must be different on each node"
2121
}
2222
},
23-
"description": "Addresses (host:port) to listen on for incoming node-to-node connections (e.g. internal consensus messages)",
23+
"description": "Addresses (host:port) to listen on for incoming node-to-node connections (e.g. internal consensus messages). IPv6 literals must be bracketed, e.g. ``[::1]:8081``",
2424
"required": ["bind_address"],
2525
"additionalProperties": false
2626
},
@@ -293,7 +293,7 @@
293293
"properties": {
294294
"target_rpc_address": {
295295
"type": "string",
296-
"description": "Address (host:port) of a node of the existing service to join"
296+
"description": "Address (host:port) of a node of the existing service to join. IPv6 literals must be bracketed, e.g. ``[2001:db8::1]:8080``"
297297
},
298298
"retry_timeout": {
299299
"type": "string",

doc/operations/configuration.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,48 @@ The configuration for each CCF node must be contained in a single JSON configura
2121
.. include:: generated_config.rst
2222

2323

24+
IPv6 Addresses
25+
--------------
26+
27+
.. note:: IPv6 support is currently **experimental**.
28+
29+
Every address field accepts an IPv4 address, an IPv6 address, or a fully qualified domain name. IPv6 literals must be written in bracketed form, ``[host]:port``, so that the colons in the address are not mistaken for the host/port separator. This applies to all address fields, including:
30+
31+
- ``network.node_to_node_interface.bind_address`` and ``published_address``
32+
- ``network.rpc_interfaces.[name].bind_address`` and ``published_address``
33+
- ``command.join.target_rpc_address``
34+
35+
For example, to bind a node's interfaces to the IPv6 loopback address ``::1`` and publish an address on a different IPv6 host:
36+
37+
.. code-block:: json
38+
39+
{
40+
"network": {
41+
"node_to_node_interface": { "bind_address": "[::1]:8081" },
42+
"rpc_interfaces": {
43+
"primary_rpc_interface": {
44+
"bind_address": "[::1]:8080",
45+
"published_address": "[2001:db8::1]:12345"
46+
}
47+
}
48+
}
49+
}
50+
51+
A node joining over IPv6 sets ``command.join.target_rpc_address`` to the bracketed address of an existing node:
52+
53+
.. code-block:: json
54+
55+
{
56+
"command": {
57+
"join": { "target_rpc_address": "[2001:db8::1]:12345" }
58+
}
59+
}
60+
61+
When a node is identified by an IPv6 literal, the matching ``node_certificate.subject_alt_names`` entry uses the ``iPAddress:`` prefix with the **unbracketed** address, for example ``"iPAddress:2001:db8::1"``.
62+
63+
.. note:: ``published_address`` defaults to ``bind_address`` when omitted, and is the address embedded in redirect URLs returned to clients. Brackets are added automatically where required, so a published IPv6 address appears in a redirect as ``https://[2001:db8::1]:12345/...``.
64+
65+
2466
Operator Features
2567
-----------------
2668

doc/operations/start_network.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ To create a new CCF network, the first node of the network should be started wit
2323

2424
The unique identifier of a CCF node is the hex-encoded string of the SHA-256 digest of the public key contained in its identity certificate (e.g. ``50211327a77fc16dd2fba8fae5fffac3df909fceeb307cf804a4125ae2679007``). This unique identifier should be used by operators and members to refer to this node with CCF (for example, when :ref:`governance/common_member_operations:Trusting a New Node`).
2525

26-
CCF nodes can be started by using IP Addresses (both IPv4 and IPv6 are supported) or by specifying a fully qualified domain name. If an FQDN is used then a ``dNSName`` subject alternative name should be specified as part of the ``node_certificate.subject_alt_names`` configuration entry. Once a DNS has been setup it will be possible to connect to the node over TLS by using the node's domain name.
26+
CCF nodes can be started by using IP Addresses (both IPv4 and IPv6 are supported; see :ref:`operations/configuration:IPv6 Addresses` for the bracketed ``[host]:port`` form required for IPv6 literals) or by specifying a fully qualified domain name. If an FQDN is used then a ``dNSName`` subject alternative name should be specified as part of the ``node_certificate.subject_alt_names`` configuration entry. Once a DNS has been setup it will be possible to connect to the node over TLS by using the node's domain name.
2727

2828
When starting up, the node generates its own key pair and outputs the unendorsed certificate associated with its public key at the location specified by the ``node_certificate_file`` configuration entry. The certificate of the freshly-created CCF network is also output at the location specified by the ``service_certificate_file`` configuration entry.
2929

include/ccf/service/node_info_network.h

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,16 +188,60 @@ namespace ccf
188188
}
189189
};
190190

191+
// Splits a NetAddress ("host:port") into its host and port components. IPv6
192+
// literals are expected in bracketed form ("[host]:port"), and the brackets
193+
// are stripped from the returned host so that it can be used directly for
194+
// resolution, certificate SANs and comparison. A port-less address ("host"
195+
// or "[host]") returns the host with an empty port. The inverse of
196+
// make_net_address.
197+
// See https://www.rfc-editor.org/info/rfc3986/#section-3.2.3
191198
inline static std::pair<std::string, std::string> split_net_address(
192199
const NodeInfoNetwork::NetAddress& addr)
193200
{
201+
if (addr.starts_with('['))
202+
{
203+
// Only treat as a bracketed IPv6 literal if it is well-formed, i.e.
204+
// exactly "[host]" or "[host]:port". Anything else (e.g.
205+
// "[::1]foo:8000") falls through to the generic parsing below rather
206+
// than being silently mis-parsed.
207+
const auto close = addr.find(']');
208+
if (
209+
close != std::string::npos &&
210+
(close + 1 == addr.size() || addr[close + 1] == ':'))
211+
{
212+
std::string host = addr.substr(1, close - 1);
213+
std::string port;
214+
if (close + 1 < addr.size())
215+
{
216+
port = addr.substr(close + 2);
217+
}
218+
return std::make_pair(std::move(host), std::move(port));
219+
}
220+
}
221+
222+
// rsplit_1 splits on the last ':'. When the address has no port it returns
223+
// ("", addr), which would wrongly put the host in the port slot; handle the
224+
// port-less case explicitly so the host stays in the first position.
225+
if (addr.find(':') == std::string::npos)
226+
{
227+
return std::make_pair(addr, std::string());
228+
}
229+
194230
auto [host, port] = ccf::nonstd::rsplit_1(addr, ":");
195231
return std::make_pair(std::string(host), std::string(port));
196232
}
197233

234+
// Combines a host and port into a NetAddress ("host:port"). IPv6 literals
235+
// (hosts containing ':') are wrapped in brackets to produce an unambiguous,
236+
// URL-safe "[host]:port" form. Idempotent for already-bracketed hosts. The
237+
// inverse of split_net_address.
198238
inline static NodeInfoNetwork::NetAddress make_net_address(
199239
const std::string& host, const std::string& port)
200240
{
241+
if (host.find(':') != std::string::npos && !host.starts_with('['))
242+
{
243+
return fmt::format("[{}]:{}", host, port);
244+
}
201245
return fmt::format("{}:{}", host, port);
202246
}
203247

src/ds/cli_helper.h

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,69 @@ namespace cli
1717
{
1818
using ParsedAddress = ccf::NodeInfoNetwork::NetAddress;
1919

20+
// Parses and validates a "host:port" (or bracketed "[host]:port") address
21+
// from untrusted CLI input. Deliberately does NOT reuse
22+
// ccf::split_net_address, despite the apparent overlap, because the two have
23+
// different contracts:
24+
// - Missing port: for a bare host like "1.2.3.4" this substitutes
25+
// default_port, returning ("1.2.3.4", default_port); split_net_address
26+
// leaves the port empty, returning ("1.2.3.4", "").
27+
// - Validation: this checks the port is numeric and in 0-65535, and throws
28+
// on malformed input (unmatched '[', junk after ']'); split_net_address
29+
// does no validation and deliberately falls through to lenient parsing.
30+
// That leniency is a safety property of split_net_address, which is on the
31+
// consensus deserialization path and must not throw on already-persisted
32+
// addresses. Validation belongs here, at the input boundary; keep them apart.
2033
static std::pair<std::string, std::string> validate_address(
2134
const ParsedAddress& addr, const std::string& default_port = "0")
2235
{
23-
auto found = addr.find_last_of(':');
24-
auto hostname = addr.substr(0, found);
36+
std::string hostname;
37+
std::string port;
2538

26-
const auto port =
27-
found == std::string::npos ? default_port : addr.substr(found + 1);
39+
if (!addr.empty() && addr.front() == '[')
40+
{
41+
// Bracketed IPv6 literal: "[host]:port" or "[host]". The brackets are
42+
// stripped from the returned host.
43+
const auto close = addr.find(']');
44+
if (close == std::string::npos)
45+
{
46+
throw std::logic_error(
47+
fmt::format("Address '{}' has an unmatched '['", addr));
48+
}
49+
hostname = addr.substr(1, close - 1);
50+
if (close + 1 == addr.size())
51+
{
52+
// "[host]" with no port
53+
port = default_port;
54+
}
55+
else if (addr[close + 1] == ':')
56+
{
57+
// "[host]:port"
58+
port = addr.substr(close + 2);
59+
}
60+
else
61+
{
62+
throw std::logic_error(fmt::format(
63+
"Address '{}' has unexpected characters after ']'", addr));
64+
}
65+
}
66+
else
67+
{
68+
// Unbracketed IPv6 literals are ambiguous with the host:port separator.
69+
// Require bracketed "[host]:port" form for any address containing more
70+
// than one ':' (e.g. "::1").
71+
if (
72+
addr.find(':') != std::string::npos &&
73+
addr.find(':') != addr.find_last_of(':'))
74+
{
75+
throw std::logic_error(fmt::format(
76+
"IPv6 address '{}' must be bracketed as '[host]:port'", addr));
77+
}
78+
79+
auto found = addr.find_last_of(':');
80+
hostname = addr.substr(0, found);
81+
port = found == std::string::npos ? default_port : addr.substr(found + 1);
82+
}
2883

2984
// Check if port is in valid range
3085
uint16_t port_n = 0;

0 commit comments

Comments
 (0)