diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 48c01c9df7d..18a85a167ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -58,6 +58,7 @@ go_deps.bzl @dfinity/idx /ic-os/components/boundary-guestos/ @dfinity/boundary-node @dfinity/node /ic-os/components/boundary-guestos.bzl @dfinity/boundary-node @dfinity/node /ic-os/components/init/bootstrap-ic-node/boundary-guestos/ @dfinity/boundary-node @dfinity/node +/ic-os/components/networking/nftables/ @dfinity/dre /toolchains/ @dfinity/node # [metrics-proxy] diff --git a/Cargo.Bazel.Fuzzing.json.lock b/Cargo.Bazel.Fuzzing.json.lock index b1998fe14f6..c3c910636ef 100644 --- a/Cargo.Bazel.Fuzzing.json.lock +++ b/Cargo.Bazel.Fuzzing.json.lock @@ -1,5 +1,5 @@ { - "checksum": "9ef6df4a9a9699fa60c0deb0f30df0446a5aaa79555f9225b15f48ccb5afca4d", + "checksum": "7680077187e67e088479ce8e663201a5bdde52f67daa6cf767109aaeea928ba0", "crates": { "abnf 0.12.0": { "name": "abnf", @@ -33348,10 +33348,20 @@ "crate_features": { "common": [ "default", + "serde", "std" ], "selects": {} }, + "deps": { + "common": [ + { + "id": "serde 1.0.203", + "target": "serde" + } + ], + "selects": {} + }, "edition": "2018", "version": "2.8.0" }, diff --git a/Cargo.Bazel.Fuzzing.toml.lock b/Cargo.Bazel.Fuzzing.toml.lock index 993388a13ad..81d864d7b5a 100644 --- a/Cargo.Bazel.Fuzzing.toml.lock +++ b/Cargo.Bazel.Fuzzing.toml.lock @@ -5866,6 +5866,9 @@ name = "ipnet" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +dependencies = [ + "serde", +] [[package]] name = "ipnetwork" diff --git a/Cargo.Bazel.json.lock b/Cargo.Bazel.json.lock index 10abf57d8f6..896499cea51 100644 --- a/Cargo.Bazel.json.lock +++ b/Cargo.Bazel.json.lock @@ -1,5 +1,5 @@ { - "checksum": "6d91b4d8565bf0af7b7870fd7a6b31e599c590eb3e7bf682ba77d1f93a035912", + "checksum": "8c7eb3b6f79b6e190dca4b2c651d578bf3b1fcf469913f067eafa7a36e320629", "crates": { "abnf 0.12.0": { "name": "abnf", @@ -33244,10 +33244,20 @@ "crate_features": { "common": [ "default", + "serde", "std" ], "selects": {} }, + "deps": { + "common": [ + { + "id": "serde 1.0.203", + "target": "serde" + } + ], + "selects": {} + }, "edition": "2018", "version": "2.8.0" }, diff --git a/Cargo.Bazel.toml.lock b/Cargo.Bazel.toml.lock index d6d1dbcd8a6..0a864973221 100644 --- a/Cargo.Bazel.toml.lock +++ b/Cargo.Bazel.toml.lock @@ -5867,6 +5867,9 @@ name = "ipnet" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +dependencies = [ + "serde", +] [[package]] name = "ipnetwork" diff --git a/Cargo.lock b/Cargo.lock index 189ad75c25e..eb5ccfe68dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2351,7 +2351,9 @@ dependencies = [ "anyhow", "clap 4.5.18", "ic-types", + "ipnet", "once_cell", + "pretty_assertions", "regex", "serde", "serde_json", @@ -13954,6 +13956,9 @@ name = "ipnet" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +dependencies = [ + "serde", +] [[package]] name = "ipnetwork" diff --git a/bazel/external_crates.bzl b/bazel/external_crates.bzl index 015dea4c7a4..0c962bd292c 100644 --- a/bazel/external_crates.bzl +++ b/bazel/external_crates.bzl @@ -661,7 +661,8 @@ def external_crates_repository(name, cargo_lockfile, lockfile, sanitizers_enable features = ["serde"], ), "ipnet": crate.spec( - version = "^2.5.0", + version = "^2.8.0", + features = ["serde"], ), "isocountry": crate.spec( version = "^0.3.2", diff --git a/ci/container/container-run.sh b/ci/container/container-run.sh index 882e81a8d90..10bcfede5d4 100755 --- a/ci/container/container-run.sh +++ b/ci/container/container-run.sh @@ -2,17 +2,17 @@ set -eEuo pipefail if [ -n "${IN_NIX_SHELL:-}" ]; then - echo "Please do not run $0 inside of nix-shell." >&2 + eprintln "Please do not run $0 inside of nix-shell." exit 1 fi if [ -e /run/.containerenv ]; then - echo "Nested $0 is not supported." >&2 + eprintln "Nested $0 is not supported." exit 1 fi if ! which podman >/dev/null 2>&1; then - echo "Podman missing...install it." >&2 + eprintln "Podman missing...install it." exit 1 fi @@ -27,6 +27,10 @@ Script uses dfinity/ic-build image by default. EOF } +eprintln() { + echo "$@" >&2 +} + if findmnt /hoststorage >/dev/null; then PODMAN_ARGS=(--root /hoststorage/podman-root) else @@ -39,17 +43,17 @@ CTR=0 while test $# -gt $CTR; do case "$1" in -h | --help) usage && exit 0 ;; - -f | --full) echo "The legacy image has been deprecated, --full is not an option anymore." && exit 0 ;; + -f | --full) eprintln "The legacy image has been deprecated, --full is not an option anymore." && exit 0 ;; -c | --cache-dir) if [[ $# -gt "$CTR + 1" ]]; then if [ ! -d "$2" ]; then - echo "$2 is not a directory! Create it and try again." + eprintln "$2 is not a directory! Create it and try again." usage && exit 1 fi CACHE_DIR="$2" - echo "Bind-mounting $CACHE_DIR as cache directory." + eprintln "Bind-mounting $CACHE_DIR as cache directory." else - echo "Missing argument for -c | --cache-dir!" + eprintln "Missing argument for -c | --cache-dir!" usage && exit 1 fi shift @@ -73,7 +77,7 @@ if ! sudo podman "${PODMAN_ARGS[@]}" image exists $IMAGE; then fi if findmnt /hoststorage >/dev/null; then - echo "Purging non-relevant container images" + eprintln "Purging non-relevant container images" sudo podman "${PODMAN_ARGS[@]}" image prune -a -f --filter "reference!=$IMAGE" fi @@ -127,6 +131,7 @@ if [ "$(id -u)" = "1000" ]; then PODMAN_RUN_ARGS+=( --mount type=bind,source="${HOME}/.bash_history",target="/home/ubuntu/.bash_history" ) + fi if [ -e "${HOME}/.local/share/fish" ]; then PODMAN_RUN_ARGS+=( @@ -151,7 +156,7 @@ if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -e "${SSH_AUTH_SOCK:-}" ]; then -e SSH_AUTH_SOCK="/ssh-agent" ) else - echo "No ssh-agent to forward." + eprintln "No ssh-agent to forward." fi # make sure we have all bind-mounts @@ -160,22 +165,28 @@ mkdir -p ~/.{aws,ssh,cache,local/share/fish} && touch ~/.{zsh,bash}_history PODMAN_RUN_USR_ARGS=() if [ -f "$HOME/.container-run.conf" ]; then # conf file with user's custom PODMAN_RUN_USR_ARGS - echo "Sourcing user's ~/.container-run.conf" + eprintln "Sourcing user's ~/.container-run.conf" source "$HOME/.container-run.conf" fi -# privileged rootful podman is required due to requirements of IC-OS guest build -# additionally, we need to use hosts's cgroups and network +# Omit -t if not a tty. +# Also shut up logging, because podman will by default log +# every byte of standard output to the journal, and that +# destroys the journal + wastes enormous amounts of CPU. +# I witnessed journald and syslog peg 2 cores of my devenv +# when running a simple cat /path/to/file. +if tty >/dev/null 2>&1; then + tty=-t +else + tty= +fi +other_args="--pids-limit=-1 -i $tty --log-driver=none --rm --privileged --network=host --cgroupns=host" +# Privileged rootful podman is required due to requirements of IC-OS guest build; +# additionally, we need to use hosts's cgroups and network. if [ $# -eq 0 ]; then set -x - sudo podman "${PODMAN_ARGS[@]}" run --pids-limit=-1 -it --rm --privileged --network=host --cgroupns=host \ - "${PODMAN_RUN_ARGS[@]}" ${PODMAN_RUN_USR_ARGS[@]} -w "$WORKDIR" \ - "$IMAGE" ${USHELL:-/usr/bin/bash} - set +x + exec sudo podman "${PODMAN_ARGS[@]}" run $other_args "${PODMAN_RUN_ARGS[@]}" ${PODMAN_RUN_USR_ARGS[@]} -w "$WORKDIR" "$IMAGE" "${USHELL}" else set -x - sudo podman "${PODMAN_ARGS[@]}" run --pids-limit=-1 -it --rm --privileged --network=host --cgroupns=host \ - "${PODMAN_RUN_ARGS[@]}" "${PODMAN_RUN_USR_ARGS[@]}" -w "$WORKDIR" \ - "$IMAGE" "$@" - set +x + exec sudo podman "${PODMAN_ARGS[@]}" run $other_args "${PODMAN_RUN_ARGS[@]}" ${PODMAN_RUN_USR_ARGS[@]} -w "$WORKDIR" "$IMAGE" "$@" fi diff --git a/ic-os/components/guestos.bzl b/ic-os/components/guestos.bzl index d1e40f2bc88..2bd845ea5c9 100644 --- a/ic-os/components/guestos.bzl +++ b/ic-os/components/guestos.bzl @@ -96,6 +96,7 @@ component_files = { Label("networking/fallback.conf"): "/etc/systemd/resolved.conf.d/fallback.conf", Label("networking/resolv.conf"): "/etc/resolv.conf", Label("networking/network-tweaks.conf"): "/etc/sysctl.d/network-tweaks.conf", + Label("networking/nftables/nftables-add-operator-rules-guestos.conf"): "/etc/systemd/system/nftables.service.d/nftables-add-operator-rules.conf", Label("networking/hosts"): "/etc/hosts", Label("networking/dev-certs/canister_http_test_ca.cert"): "/dev-certs/canister_http_test_ca.cert", diff --git a/ic-os/components/hostos-scripts/build-bootstrap-config-image.sh b/ic-os/components/hostos-scripts/build-bootstrap-config-image.sh index ec1f21cfd1e..ff8a1bf2435 100755 --- a/ic-os/components/hostos-scripts/build-bootstrap-config-image.sh +++ b/ic-os/components/hostos-scripts/build-bootstrap-config-image.sh @@ -110,6 +110,9 @@ options may be specified: --socks_proxy url The URL of the socks proxy to use. To be used in systems tests only. + + --firewall_rules_file path + Optional. Should point to a file containing a firewall.json rules file. EOF } @@ -130,6 +133,7 @@ function build_ic_bootstrap_tar() { local QUERY_STATS_EPOCH_LENGTH local BITCOIND_ADDR local JAEGER_ADDR + local FIREWALL_RULES_FILE while true; do if [ $# == 0 ]; then @@ -203,6 +207,9 @@ function build_ic_bootstrap_tar() { --socks_proxy) SOCKS_PROXY="$2" ;; + --firewall_rules_file) + FIREWALL_RULES_FILE="$2" + ;; *) echo "Unrecognized option: $1" usage @@ -274,6 +281,9 @@ EOF if [ "${NODE_OPERATOR_PRIVATE_KEY}" != "" ]; then cp "${NODE_OPERATOR_PRIVATE_KEY}" "${BOOTSTRAP_TMPDIR}/node_operator_private_key.pem" fi + if [ "${FIREWALL_RULES_FILE}" != "" ]; then + cp "${FIREWALL_RULES_FILE}" "${BOOTSTRAP_TMPDIR}/firewall.json" + fi tar cf "${OUT_FILE}" \ --sort=name \ diff --git a/ic-os/components/hostos-scripts/generate-guestos-config/generate-guestos-config.sh b/ic-os/components/hostos-scripts/generate-guestos-config/generate-guestos-config.sh index cca55130bf4..e0e15dcc656 100755 --- a/ic-os/components/hostos-scripts/generate-guestos-config/generate-guestos-config.sh +++ b/ic-os/components/hostos-scripts/generate-guestos-config/generate-guestos-config.sh @@ -31,6 +31,7 @@ Arguments: -h, --help show this help message and exit -i=, --input= specify the input template file (Default: /opt/ic/share/guestos.xml.template) -m=, --media= specify the config media image file (Default: /run/ic-node/config.img) + -f=, --firewall= specify the firewall.json configuration file (Default: /boot/config/firewall.json) -o=, --output= specify the output configuration file (Default: /var/lib/libvirt/guestos.xml) ' exit 1 @@ -43,6 +44,10 @@ Arguments: MEDIA="${argument#*=}" shift ;; + -f=* | --firewall=*) + FIREWALL="${argument#*=}" + shift + ;; -o=* | --output=*) OUTPUT="${argument#*=}" shift @@ -58,6 +63,10 @@ function validate_arguments() { if [ "${CONFIG}" == "" -o "${DEPLOYMENT}" == "" -o "${INPUT}" == "" -o "${OUTPUT}" == "" ]; then $0 --help fi + if [ "${FIREWALL}" != "" -a ! -f "${FIREWALL}" ]; then + echo >&2 "Error: specified firewall rules file $FIREWALL does not exist" + $0 --help + fi } # Set arguments if undefined @@ -65,6 +74,7 @@ CONFIG="${CONFIG:=/boot/config/config.ini}" DEPLOYMENT="${DEPLOYMENT:=/boot/config/deployment.json}" INPUT="${INPUT:=/opt/ic/share/guestos.xml.template}" MEDIA="${MEDIA:=/run/ic-node/config.img}" +FIREWALL="${DEPLOYMENT:=/boot/config/firewall.json}" OUTPUT="${OUTPUT:=/var/lib/libvirt/guestos.xml}" function read_variables() { @@ -85,6 +95,9 @@ function read_variables() { function assemble_config_media() { cmd=(/opt/ic/bin/build-bootstrap-config-image.sh ${MEDIA}) cmd+=(--nns_public_key "/boot/config/nns_public_key.pem") + if [ -f "${FIREWALL}" ]; then + cmd+=(--firewall_rules_file "${FIREWALL}") + fi cmd+=(--elasticsearch_hosts "$(/opt/ic/bin/fetch-property.sh --key=.logging.hosts --metric=hostos_logging_hosts --config=${DEPLOYMENT})") cmd+=(--ipv6_address "$(/opt/ic/bin/hostos_tool generate-ipv6-address --node-type GuestOS)") cmd+=(--ipv6_gateway "${ipv6_gateway}") diff --git a/ic-os/components/hostos.bzl b/ic-os/components/hostos.bzl index c40901fbcaa..b83bc7e3010 100644 --- a/ic-os/components/hostos.bzl +++ b/ic-os/components/hostos.bzl @@ -79,6 +79,7 @@ component_files = { Label("networking/resolv.conf"): "/etc/resolv.conf", Label("networking/network-tweaks.conf"): "/etc/sysctl.d/network-tweaks.conf", Label("networking/nftables/nftables-hostos.conf"): "/etc/nftables.conf", + Label("networking/nftables/nftables-add-operator-rules-hostos.conf"): "/etc/systemd/system/nftables.service.d/nftables-add-operator-rules.conf", Label("networking/hosts"): "/etc/hosts", # ssh diff --git a/ic-os/components/ic/ic.json5.template b/ic-os/components/ic/ic.json5.template index 5d4e770bbbb..979f8335088 100644 --- a/ic-os/components/ic/ic.json5.template +++ b/ic-os/components/ic/ic.json5.template @@ -190,6 +190,9 @@ \n\ counter rate_limit_v4_counter {}\n\ counter connection_limit_v4_counter {}\n\ +\n\ + chain provider_INPUT {\n\ + }\n\ \n\ chain INPUT {\n\ type filter hook input priority 0; policy drop;\n\ @@ -207,6 +210,7 @@ ct state { invalid } drop\n\ # - The rule accepts all established and related connections. It's required for the IPv4 connectivity check.\n\ ct state { established, related } accept\n\ + jump provider_INPUT\n\ log prefix \"Drop - default policy: \"\n\ }\n\ \n\ @@ -236,6 +240,9 @@ table ip6 filter {\n\ \n\ counter rate_limit_v6_counter {}\n\ counter connection_limit_v6_counter {}\n\ +\n\ + chain provider_INPUT {\n\ + }\n\ \n\ chain INPUT {\n\ type filter hook input priority 0; policy drop;\n\ @@ -259,6 +266,7 @@ table ip6 filter {\n\ ip6 saddr { hostos } ct state { new } tcp dport { 42372 } accept # Allow access from HostOS metrics-proxy so GuestOS metrics-proxy can proxy certain metrics to HostOS.\n\ <>\n\ <>\n\ + jump provider_INPUT\n\ log prefix \"Drop - default policy: \"\n\ }\n\ \n\ diff --git a/ic-os/components/networking/nftables/nftables-add-operator-rules-guestos.conf b/ic-os/components/networking/nftables/nftables-add-operator-rules-guestos.conf new file mode 100644 index 00000000000..dc8d743ff7c --- /dev/null +++ b/ic-os/components/networking/nftables/nftables-add-operator-rules-guestos.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart=/usr/bin/bash -c 'set -o pipefail ; /opt/ic/bin/guestos_tool render-firewall-config | nft -f -' +ExecReload=/usr/bin/bash -c 'set -o pipefail ; /opt/ic/bin/guestos_tool render-firewall-config | nft -f -' diff --git a/ic-os/components/networking/nftables/nftables-add-operator-rules-hostos.conf b/ic-os/components/networking/nftables/nftables-add-operator-rules-hostos.conf new file mode 100644 index 00000000000..37994faad2a --- /dev/null +++ b/ic-os/components/networking/nftables/nftables-add-operator-rules-hostos.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart=/usr/bin/bash -c 'set -o pipefail ; /opt/ic/bin/hostos_tool render-firewall-config | nft -f -' +ExecReload=/usr/bin/bash -c 'set -o pipefail ; /opt/ic/bin/hostos_tool render-firewall-config | nft -f -' diff --git a/ic-os/components/networking/nftables/nftables-hostos.conf b/ic-os/components/networking/nftables/nftables-hostos.conf index 47d21cfc982..24da6ae0743 100644 --- a/ic-os/components/networking/nftables/nftables-hostos.conf +++ b/ic-os/components/networking/nftables/nftables-hostos.conf @@ -27,6 +27,9 @@ table ip filter { accept } + chain provider_INPUT { + } + chain INPUT { type filter hook input priority filter; policy drop; iif "lo" accept @@ -40,6 +43,7 @@ table ip filter { icmp type echo-reply accept ip saddr @local_networks ct state { new } tcp dport { 22 } accept ip saddr @local_networks ct state { new } udp dport { 67 } accept + jump provider_INPUT tcp dport { 42372 } goto metrics_proxy } @@ -175,6 +179,9 @@ table ip6 filter { accept } + chain provider_INPUT { + } + chain INPUT { type filter hook input priority filter; policy drop; iif "lo" accept @@ -192,6 +199,7 @@ table ip6 filter { ip6 saddr @dfinity_dcs ct state { new } tcp dport { 22, 9100, 19531, 19100 } accept ip6 saddr @telemetry_clients ct state { new } tcp dport { 9100, 19531, 19100 } accept ip6 saddr @node_providers ct state { new } tcp dport { 22, 9100, 19531 } accept + jump provider_INPUT tcp dport { 42372 } goto metrics_proxy } diff --git a/ic-os/components/selinux/ic-node/ic-node.te b/ic-os/components/selinux/ic-node/ic-node.te index 3feaab8fc3b..9136e65745b 100644 --- a/ic-os/components/selinux/ic-node/ic-node.te +++ b/ic-os/components/selinux/ic-node/ic-node.te @@ -263,6 +263,9 @@ require { type iptables_t; } search_dirs_pattern(iptables_t, var_run_t, ic_var_run_t) search_dirs_pattern(iptables_t, ic_var_run_t, ic_nftables_ruleset_t) read_files_pattern(iptables_t, ic_nftables_ruleset_t, ic_nftables_ruleset_t) +# Allow nft -f - to read from standard input when started as a systemd service. +require { type initrc_t; } +allow iptables_t initrc_t : fifo_file { open read }; # Allow orchestrator to write nftables ruleset manage_files_pattern(ic_orchestrator_t, ic_nftables_ruleset_t, ic_nftables_ruleset_t) diff --git a/ic-os/components/setupos-scripts/check-hardware.sh b/ic-os/components/setupos-scripts/check-hardware.sh index 97770a9d8bd..bfcfd9ffc2d 100644 --- a/ic-os/components/setupos-scripts/check-hardware.sh +++ b/ic-os/components/setupos-scripts/check-hardware.sh @@ -263,10 +263,15 @@ function verify_deployment_path() { main() { source /opt/ic/bin/functions.sh log_start "$(basename $0)" - check_generation - verify_cpu - verify_memory - verify_disks + if kernel_cmdline_bool_default_true ic.setupos.check_hardware; then + check_generation + verify_cpu + verify_memory + verify_disks + else + echo "* Hardware checks skipped by request via kernel command line" + GENERATION=2 + fi verify_deployment_path log_end "$(basename $0)" } diff --git a/ic-os/components/setupos-scripts/check-network.sh b/ic-os/components/setupos-scripts/check-network.sh index 7d6b5ad7624..f2247ca9e0d 100755 --- a/ic-os/components/setupos-scripts/check-network.sh +++ b/ic-os/components/setupos-scripts/check-network.sh @@ -8,6 +8,11 @@ PATH="/sbin:/bin:/usr/sbin:/usr/bin" CONFIG="${CONFIG:=/var/ic/config/config.ini}" DEPLOYMENT="${DEPLOYMENT:=/data/deployment.json}" +# Overridable with $FIREWALL_FILE. +# FIREWALL_FILE must not be defaulted, because the logic +# to check an explicitly-specified firewall file is different +# from the logic to check the default one. +DEFAULT_FIREWALL_FILE="/var/ic/config/firewall.json" function read_variables() { # Read limited set of keys. Be extra-careful quoting values as it could @@ -89,6 +94,31 @@ function get_network_settings() { GUESTOS_IPV6_ADDRESS=$(/opt/ic/bin/setupos_tool generate-ipv6-address --node-type GuestOS) } +function check_firewall_rules() { + local ret=0 + if [ -v FIREWALL_FILE ]; then + echo "* Checking firewall rules in ${FIREWALL_FILE}..." + test -f "${FIREWALL_FILE}" || { + echo >&2 "Failed to read explicitly-specified firewall file ${FIREWALL_FILE}" + return 1 + } + /opt/ic/bin/setupos_tool check-firewall-config "${FIREWALL_FILE}" >/dev/null || { + ret=$? + echo >&2 "Failed to parse explicitly-specified firewall rules file ${FIREWALL_FILE}." + return ${ret} + } + else + if [ -f "${DEFAULT_FIREWALL_FILE}" ]; then + echo "* Checking firewall rules in ${DEFAULT_FIREWALL_FILE}..." + /opt/ic/bin/setupos_tool check-firewall-config "${DEFAULT_FIREWALL_FILE}" >/dev/null || { + ret=$? + echo >&2 "Failed to parse default firewall rules file ${DEFAULT_FIREWALL_FILE}." + return ${ret} + } + fi + fi +} + function print_network_settings() { echo "* Printing user defined network settings..." echo " IPv6 Prefix : ${ipv6_prefix}" @@ -202,19 +232,24 @@ function query_nns_nodes() { main() { source /opt/ic/bin/functions.sh log_start "$(basename $0)" + check_firewall_rules read_variables - get_network_settings - print_network_settings + if kernel_cmdline_bool_default_true ic.setupos.check_network; then + get_network_settings + print_network_settings + + if [[ -n ${ipv4_address} && -n ${ipv4_prefix_length} && -n ${ipv4_gateway} ]]; then + validate_domain_name + setup_ipv4_network + ping_ipv4_gateway + fi - if [[ -n ${ipv4_address} && -n ${ipv4_prefix_length} && -n ${ipv4_gateway} ]]; then - validate_domain_name - setup_ipv4_network - ping_ipv4_gateway + ping_ipv6_gateway + assemble_nns_nodes_list + query_nns_nodes + else + echo "* Network checks skipped by request via kernel command line" fi - - ping_ipv6_gateway - assemble_nns_nodes_list - query_nns_nodes log_end "$(basename $0)" } diff --git a/ic-os/components/setupos-scripts/config.sh b/ic-os/components/setupos-scripts/config.sh index 0b13fb29296..0fc85272849 100755 --- a/ic-os/components/setupos-scripts/config.sh +++ b/ic-os/components/setupos-scripts/config.sh @@ -12,6 +12,8 @@ CONFIG_INI="${CONFIG_DIR}/config.ini" CONFIG_INI_CLONE="${CONFIG_TMP}/config.ini" SSH_AUTHORIZED_KEYS="${CONFIG_DIR}/ssh_authorized_keys" SSH_AUTHORIZED_KEYS_CLONE="${CONFIG_TMP}/ssh_authorized_keys" +FIREWALL_JSON="${CONFIG_DIR}/firewall.json" +FIREWALL_JSON_CLONE="${CONFIG_TMP}/firewall.json" # Define empty variables so they are not unset ipv6_prefix="" @@ -58,6 +60,11 @@ function clone_config() { if [ ! -d "${SSH_AUTHORIZED_KEYS_CLONE}" ]; then log_and_halt_installation_on_error "1" "Cloned 'ssh_authorized_keys' directory does not exist." fi + + if [ -f "${FIREWALL_JSON}" ]; then + cp ${FIREWALL_JSON} ${FIREWALL_JSON_CLONE} + log_and_halt_installation_on_error "${?}" "Unable to copy 'firewall.json' firewall rules file." + fi } function normalize_config() { diff --git a/ic-os/components/setupos-scripts/functions.sh b/ic-os/components/setupos-scripts/functions.sh index c058b1885c2..87cfc04d3d5 100755 --- a/ic-os/components/setupos-scripts/functions.sh +++ b/ic-os/components/setupos-scripts/functions.sh @@ -66,3 +66,43 @@ function find_first_drive() { function get_large_drives() { lsblk -nld -o NAME,SIZE | grep 'T$' | grep -o '^\S*' } + +# Check if a kernel command line boolean is set to 1 (true). +# If set to 1, return true (0). +# If set to 0, return false (1). +# if absent or any other value, return true (1). +function kernel_cmdline_bool_default_true() { + # Add spaces at the beginning and the end for greppability. + local cmdline=" $(cat /proc/cmdline) " + if echo "$cmdline" | grep -qF " $1=1 "; then + return 0 + fi + # Covers the case where the option is present without =1 as value. + if echo "$cmdline" | grep -qF " $1 "; then + return 0 + fi + if echo "$cmdline" | grep -qF " $1=0 "; then + return 1 + fi + return 0 +} + +# Check if a kernel command line boolean is set to 1 (true). +# If set to 1, return true (0). +# If set to 0, return false (1). +# if absent or any other value, return false (1). +function kernel_cmdline_bool_default_false() { + # Add spaces at the beginning and the end for greppability. + local cmdline=" $(cat /proc/cmdline) " + if echo "$cmdline" | grep -qF " $1=1"; then + return 0 + fi + # Covers the case where the option is present without =1 as value. + if echo "$cmdline" | grep -qF " $1 "; then + return 0 + fi + if echo "$cmdline" | grep -qF " $1=0"; then + return 1 + fi + return 1 +} diff --git a/ic-os/components/setupos-scripts/install-guestos.sh b/ic-os/components/setupos-scripts/install-guestos.sh index 2311c3ba9c3..942bdb5977a 100755 --- a/ic-os/components/setupos-scripts/install-guestos.sh +++ b/ic-os/components/setupos-scripts/install-guestos.sh @@ -15,12 +15,19 @@ function install_guestos() { log_and_halt_installation_on_error "${?}" "Unable to activate HostOS volume group." TMPDIR=$(mktemp -d) + # Release RAM. Cannot be run concurrently with install-hostos.sh. + rm -f disk.img + # Extract the disk image to RAM. + echo "* Temporarily extracting the GuestOS image to RAM; please stand by for a few seconds" tar xafS /data/guest-os.img.tar.zst -C "${TMPDIR}" disk.img - - size=$(wc -c <"${TMPDIR}/disk.img") - size="${size:=0}" - - pv -f -s "$size" "${TMPDIR}/disk.img" | dd of=${LV} bs=10M conv=sparse + log_and_halt_installation_on_error "${?}" "Unable to extract GuestOS disk-image." + # Duplicate the image to the disk. + # Progress is handled by status=progress. + # Makes a huge difference when running the setup under QEMU with no KVM. + # dd will detect nulls in chunks of 4M and sparsify the writes. + # In *non-KVM-accelerated* VM, this goes 500 MB/s, three times as fast as before. + echo "* Writing the GuestOS image to ${LV}" + dd if="${TMPDIR}/disk.img" of=${LV} bs=10M conv=sparse status=progress log_and_halt_installation_on_error "${?}" "Unable to install GuestOS disk-image." rm -rf "${TMPDIR}" diff --git a/ic-os/components/setupos-scripts/install-hostos.sh b/ic-os/components/setupos-scripts/install-hostos.sh index bb3d3b2f424..f734d1a7d9d 100755 --- a/ic-os/components/setupos-scripts/install-hostos.sh +++ b/ic-os/components/setupos-scripts/install-hostos.sh @@ -10,14 +10,22 @@ function install_hostos() { echo "* Installing HostOS disk-image..." target_drive=$(find_first_drive) + echo "* HostOS will be deployed to /dev/${target_drive}" TMPDIR=$(mktemp -d) + # Release RAM. Cannot be run concurrently with install-guestos.sh. + rm -f disk.img + # Extract the disk image to RAM. + echo "* Temporarily extracting the HostOS image to memory; please stand by for a few seconds" tar xafS /data/host-os.img.tar.zst -C "${TMPDIR}" disk.img - - size=$(wc -c <"${TMPDIR}/disk.img") - size="${size:=0}" - - pv -f -s "$size" "${TMPDIR}/disk.img" | dd of="/dev/${target_drive}" bs=10M conv=sparse + log_and_halt_installation_on_error "${?}" "Unable to extract HostOS disk-image." + # Duplicate the image to the disk. + # Progress is handled by status=progress. + # Makes a huge difference when running the setup under QEMU with no KVM. + # dd will detect nulls in chunks of 4M and sparsify the writes. + # In *non-KVM-accelerated* VM, this goes 500 MB/s, three times as fast as before. + echo "* Writing the HostOS image to /dev/${target_drive}" + dd if="${TMPDIR}/disk.img" of="/dev/${target_drive}" bs=4M conv=sparse status=progress log_and_halt_installation_on_error "${?}" "Unable to install HostOS disk-image on drive: /dev/${target_drive}" rm -rf "${TMPDIR}" diff --git a/ic-os/components/setupos-scripts/setup-hostos-config.sh b/ic-os/components/setupos-scripts/setup-hostos-config.sh index 1fc04a959fc..5622b8eb951 100755 --- a/ic-os/components/setupos-scripts/setup-hostos-config.sh +++ b/ic-os/components/setupos-scripts/setup-hostos-config.sh @@ -26,6 +26,21 @@ function copy_config_files() { log_and_halt_installation_on_error "1" "Configuration file 'config.ini' does not exist." fi + if [ -v FIREWALL_FILE ]; then + # This file has been checked for correctness before in check_firewall_rules. + echo "* Copying '${FIREWALL_FILE}' as firewall.json to hostOS config partition..." + cp ${FIREWALL_FILE} /media/firewall.json + log_and_halt_installation_on_error "${?}" "Unable to copy '${FIREWALL_FILE}' to hostOS config partition." + else + # This file is optional. It will not be used if absent. If present, it + # has already been checked for consistency before this step. + if [ -f "${CONFIG_DIR}/firewall.json" ]; then + echo "* Copying 'firewall.json' to hostOS config partition..." + cp ${CONFIG_DIR}/firewall.json /media/firewall.json + log_and_halt_installation_on_error "${?}" "Unable to copy 'firewall.json' to hostOS config partition." + fi + fi + echo "* Copying SSH authorized keys..." if [ -d "${CONFIG_DIR}/ssh_authorized_keys" ]; then cp -r ${CONFIG_DIR}/ssh_authorized_keys /media/ diff --git a/ic-os/components/setupos-scripts/setupos.sh b/ic-os/components/setupos-scripts/setupos.sh index 413f6453c1e..e3440168de4 100755 --- a/ic-os/components/setupos-scripts/setupos.sh +++ b/ic-os/components/setupos-scripts/setupos.sh @@ -40,10 +40,18 @@ main() { /opt/ic/bin/check-setupos-age.sh /opt/ic/bin/check-hardware.sh /opt/ic/bin/check-network.sh + if kernel_cmdline_bool_default_false ic.setupos.stop_before_installation; then + echo "* Installation skipped by request via kernel command line" + exit + fi /opt/ic/bin/setup-disk.sh /opt/ic/bin/install-hostos.sh /opt/ic/bin/install-guestos.sh /opt/ic/bin/setup-hostos-config.sh + if kernel_cmdline_bool_default_true ic.setupos.reboot_after_installation; then + echo "* Reboot skipped by request via kernel command line" + exit + fi reboot_setupos log_end "$(basename $0)" } diff --git a/ic-os/defs.bzl b/ic-os/defs.bzl index 5f2a4384cfd..8ebb2181cab 100644 --- a/ic-os/defs.bzl +++ b/ic-os/defs.bzl @@ -553,17 +553,19 @@ EOF ], outs = ["launch_local_vm_script"], cmd = """ - IMAGE="$(location :disk-img.tar)" + IMAGE="$$PWD/$(location :disk-img.tar)" cat < $@ #!/usr/bin/env bash set -euo pipefail cd "\\$$BUILD_WORKSPACE_DIRECTORY" -TEMP=\\$$(mktemp -d) +TEMP=\\$$(mktemp -d --suffix=.qemu-launch-remote-vm) +# Clean up after ourselves when exiting. +trap 'rm -rf \\$$TEMP' EXIT CID=\\$$((\\$$RANDOM + 3)) -cp $$IMAGE \\$$TEMP cd \\$$TEMP -tar xf disk-img.tar -qemu-system-x86_64 -machine type=q35,accel=kvm -enable-kvm -nographic -m 4G -bios /usr/share/ovmf/OVMF.fd -device vhost-vsock-pci,guest-cid=\\$$CID -drive file=disk.img,format=raw,if=virtio -netdev user,id=user.0,hostfwd=tcp::2222-:22 -device virtio-net,netdev=user.0 +tar xSf $$IMAGE +truncate -s 128G target.img +qemu-system-x86_64 -machine type=q35,accel=kvm -enable-kvm -nographic -m 4G -bios /usr/share/ovmf/OVMF.fd -device vhost-vsock-pci,guest-cid=\\$$CID -boot d -drive file=target.img,format=raw,if=virtio -drive file=disk.img,format=raw,if=virtio -netdev user,id=user.0,hostfwd=tcp::2222-:22 -device virtio-net,netdev=user.0 EOF """, executable = True, @@ -579,17 +581,19 @@ EOF ], outs = ["launch_local_vm_script_no_kvm"], cmd = """ - IMAGE="$(location :disk-img.tar)" + IMAGE="$$PWD/$(location :disk-img.tar)" cat < $@ #!/usr/bin/env bash set -euo pipefail cd "\\$$BUILD_WORKSPACE_DIRECTORY" -TEMP=\\$$(mktemp -d) +TEMP=\\$$(mktemp -d --suffix=.qemu-launch-remote-vm) +# Clean up after ourselves when exiting. +trap 'rm -rf \\$$TEMP' EXIT CID=\\$$((\\$$RANDOM + 3)) -cp $$IMAGE \\$$TEMP cd \\$$TEMP -tar xf disk-img.tar -qemu-system-x86_64 -machine type=q35 -nographic -m 4G -bios /usr/share/ovmf/OVMF.fd -drive file=disk.img,format=raw,if=virtio -netdev user,id=user.0,hostfwd=tcp::2222-:22 -device virtio-net,netdev=user.0 +tar xSf $$IMAGE +truncate -s 128G target.img +qemu-system-x86_64 -machine type=q35 -nographic -m 4G -bios /usr/share/ovmf/OVMF.fd -boot d -drive file=target.img,format=raw,if=virtio -drive file=disk.img,format=raw,if=virtio -netdev user,id=user.0,hostfwd=tcp::2222-:22 -device virtio-net,netdev=user.0 EOF """, executable = True, diff --git a/ic-os/dev-tools/bare_metal_deployment/README.md b/ic-os/dev-tools/bare_metal_deployment/README.md index e525b1ce01a..5caccdbfb13 100644 --- a/ic-os/dev-tools/bare_metal_deployment/README.md +++ b/ic-os/dev-tools/bare_metal_deployment/README.md @@ -44,11 +44,14 @@ file_share_image_filename: file_share_username: # NOTE SSH KEYS ARE ASSUMED TO BE FUNCTIONAL inject_image_ipv6_prefix: inject_image_ipv6_gateway: +inject_firewall_json: ``` These are CLI args submitted in yaml form. See [why](#why-two-config-files) or `./deploy.py --help` for detailed docs on the arguments. See ./example_config.yaml for a functional example. +For information on the firewall configuration format, please consult link:../../docs/Network-Configuration.adoc[Network Configuration]. + #### What's in the csv secrets file? Per-machine BMC secrets. Each row represents a machine. The tool will deploy to each with the given information. diff --git a/ic-os/dev-tools/bare_metal_deployment/deploy.py b/ic-os/dev-tools/bare_metal_deployment/deploy.py index 8710f57dd00..09d9ec3fd0e 100755 --- a/ic-os/dev-tools/bare_metal_deployment/deploy.py +++ b/ic-os/dev-tools/bare_metal_deployment/deploy.py @@ -10,6 +10,7 @@ from ipaddress import IPv6Address from multiprocessing import Pool from pathlib import Path +from shlex import quote from typing import Any, List, Optional import fabric @@ -95,6 +96,9 @@ class Args: # If present - decompress `upload_img` and inject this into ssh_authorized_keys/admin inject_image_pub_key: Optional[str] = None + # If present - decompress `upload_img` and inject this text into firewall.json + inject_firewall_json: Optional[str] = None + # Path to the setupos-inject-configuration tool. Necessary if any inject* args are present inject_configuration_tool: Optional[str] = None @@ -464,10 +468,10 @@ def benchmark_node(bmc_info: BMCInfo, benchmark_driver_script: str, benchmark_ru ip_address = bmc_info.guestos_ipv6_address - benchmark_tools = " ".join(benchmark_tools) if benchmark_tools is not None else "" + benchmark_tools_quoted_list_of_commands = " ".join(quote(t) for t in benchmark_tools) # Throw away the result, for now - invoke.run(f"{benchmark_driver_script} {benchmark_runner_script} {file_share_ssh_key} {ip_address} {benchmark_tools}", warn=True) + invoke.run(f"{benchmark_driver_script} {benchmark_runner_script} {file_share_ssh_key} {ip_address} {benchmark_tools_quoted_list_of_commands}", warn=True) return OperationResult(bmc_info, success=True) @@ -551,7 +555,8 @@ def inject_config_into_image(setupos_inject_configuration_path: Path, ipv6_gateway: str, ipv4_args: Optional[Ipv4Args], verbose: Optional[str], - pub_key: Optional[str]) -> Path: + pub_key: Optional[str], + firewall_json: Optional[str]) -> Path: """ Transform the compressed image. * Decompress image into working_dir @@ -569,28 +574,25 @@ def is_executable(p: Path) -> bool: invoke.run(f"tar --extract --zstd --file {compressed_image_path} --directory {working_dir}", echo=True) - img_path = Path(f"{working_dir}/disk.img") + img_path = Path(os.path.join(working_dir, "disk.img")) assert img_path.exists() - image_part = f"--image-path {img_path}" - prefix_part = f"--ipv6-prefix {ipv6_prefix}" - gateway_part = f"--ipv6-gateway {ipv6_gateway}" + image_part = f"--image-path {quote(str(img_path))}" + prefix_part = f"--ipv6-prefix {quote(ipv6_prefix)}" + gateway_part = f"--ipv6-gateway {quote(ipv6_gateway)}" ipv4_part = "" if ipv4_args: - ipv4_part = f"--ipv4-address {ipv4_args.address} " - ipv4_part += f"--ipv4-gateway {ipv4_args.gateway} " - ipv4_part += f"--ipv4-prefix-length {ipv4_args.prefix_length} " - ipv4_part += f"--domain {ipv4_args.domain} " + ipv4_part = f"--ipv4-address {quote(ipv4_args.address)} " + ipv4_part += f"--ipv4-gateway {quote(ipv4_args.gateway)} " + ipv4_part += f"--ipv4-prefix-length {quote(ipv4_args.prefix_length)} " + ipv4_part += f"--domain {quote(ipv4_args.domain)}" - verbose_part = "" - if verbose: - verbose_part = f"--verbose {verbose} " + verbose_part = "" if not verbose else f"--verbose {quote(verbose)}" + firewall_json_part = "" if not firewall_json else f"--firewall-json {quote(firewall_json)}" - admin_key_part = "" - if pub_key: - admin_key_part = f"--public-keys \"{pub_key}\"" + admin_key_part = "" if not pub_key else f"--public-keys {quote(pub_key)}" - invoke.run(f"{setupos_inject_configuration_path} {image_part} {prefix_part} {gateway_part} {ipv4_part} {verbose_part} {admin_key_part}", echo=True) + invoke.run(f"{setupos_inject_configuration_path} {image_part} {prefix_part} {gateway_part} {ipv4_part} {verbose_part} {admin_key_part} {firewall_json_part}", echo=True) # Reuse the name of the compressed image path in the working directory result_filename = compressed_image_path.name @@ -656,8 +658,9 @@ def main(): args.inject_image_ipv6_gateway, ipv4_args, args.inject_image_verbose, - args.inject_image_pub_key - ) + args.inject_image_pub_key, + args.inject_firewall_json, + ) upload_to_file_share( modified_image_path, diff --git a/ic-os/docs/Configuration.adoc b/ic-os/docs/Configuration.adoc index d505d0359b3..dbba6e23f25 100644 --- a/ic-os/docs/Configuration.adoc +++ b/ic-os/docs/Configuration.adoc @@ -19,6 +19,7 @@ SetupOS validates, sanitizes, and copies all of its configuration files to the H node_operator_private_key.pem # Node Operator private key created in the Node Provider onboarding deployment.json # Deployment-specific configurations nns_public_key.pem # NNS public key + firewall.json # Firewall rules; refer to Network-Configuration.adoc for more information Refer to link:../../rs/ic_os/config/README.md[rs/ic_os/config] & link:../components/setupos-scripts/setup-hostos-config.sh[setup-hostos-config.sh] diff --git a/ic-os/docs/Network-Configuration.adoc b/ic-os/docs/Network-Configuration.adoc index 28e634cdc7b..c80f9ab7b10 100644 --- a/ic-os/docs/Network-Configuration.adoc +++ b/ic-os/docs/Network-Configuration.adoc @@ -119,3 +119,66 @@ In other words, the prefix of the EUI-64 formatted IPv6 SLAAC address is swapped When the corresponding IPv6 address is assigned, the IEEE’s 64-bit Extended Unique Identifier (EUI-64) format is followed. In this convention, the interface’s unique 48-bit MAC address is reformatted to match the EUI-64 specifications. The network part (i.e. +ipv6_prefix+) of the IPv6 address is retrieved from the +config.ini+ configuration file. The host part is the EUI-64 formatted address. + +== Firewalling + +IC-OS supports firewall traffic control from certain hosts or subnets, through ++firewall.json+ in the SetupOS config partition. This file is copied during setup +into both the HostOS and the GuestOS config partitions. The firewall rules +are specified as a list: + + [ + {...firewall rule...}, + {...firewall rule...}, + ... + ] + +Each element on the list is a dictionary that contains the following keys: + +* `from`: IPv4 / IPv6 address or subnet, with netmask (prefix length) after a slash. + Example: `2001:db8:abcd:0012::0/64`. This is mandatory and it must validate as + a valid address and netmask, otherwise firewall configuration will be ineffective. +* `to`: Either `HostOS` or `GuestOS` or `both` (capitalized that way), or omit it to + indicate that the rule applies to both compartments (same as `both`). +* `protocol`: `tcp`, `udp`, or `all` (default `all`). +* `from_ports` / `to_ports`: an integer port or a dash-separated port range from zero + to 65535, indicating the source or destination ports to be opened or blocked. This + requires that protocol be `tcp` or `udp`, else it is ignored. If unspecified, all + traffic using that protocol is covered by the rule. +* `action`: Either `accept` or `drop`, or omit it to mean `accept`. Traffic is + normally dropped in almost all circumstances. +* `comment`: Freeform text that isn't used for anything other than your own reference. + +Inbound traffic is processed by the firewall engine in the order of the rules as they +are seen, but note that rules for IPv6 and IPV4 are processed *separately in order* +because the IPv6 and IPv4 stacks process packets separately from each other. +Some rules that IC-OS needs to operate normally will always be processed prior to these +custom rules. Any other inbound traffic not covered by the rules will be dropped. + +Here is a more complete example of a valid +firewall.json+ snippet: + +[,json] +--- + [ + { + "from": "2001:db8:abcd:0012::0/64", + "to": "GuestOS" + }, + { + "from": "2001:db8:abcd:0013::0/64", + "to": "HostOS", + "protocol": "tcp", + "action": "drop" + }, + { + "from": "12.13.14.0/24", + "to": "HostOS", + "action": "accept" + } + { + "from": "200.40.55.0/24", + "to": "GuestOS", + "to_ports": "34107-34109" + "action": "accept" + } + ] diff --git a/ic-os/guestos/README.adoc b/ic-os/guestos/README.adoc index 320e6fed478..4e7c502fbfe 100644 --- a/ic-os/guestos/README.adoc +++ b/ic-os/guestos/README.adoc @@ -49,3 +49,8 @@ See instructions link:components/README.adoc#[here] on how to make changes to th For further reading, see the docs in the link:docs/README.adoc#[docs/ subdirectory] + +== Firewall + +The GuestOS firewall can be altered by operators to suit their needs. +Please consult link:../docs/Network-Configuration.adoc[the documentation for network configuration] for more information. diff --git a/ic-os/hostos/README.adoc b/ic-os/hostos/README.adoc index 8d457d28ceb..3a72d034a51 100644 --- a/ic-os/hostos/README.adoc +++ b/ic-os/hostos/README.adoc @@ -96,8 +96,10 @@ This configuration ensures that the physical CPU topology is reflected in the vi == Firewall The hard-coded firewall ruleset is rather restrictive. A new disk-image has to be proposed and blessed in order to update the rules. +However, operators can alter the firewall rules in their deployment -- please see +link:../docs/Network-Configuration.adoc[the documentation for network configuration] for more information. -Please find the raw HostOS NFTables ruleset in `nftables.conf` +Please find the default raw HostOS NFTables ruleset in `nftables.conf` === Filter diff --git a/ic-os/setupos/BUILD.bazel b/ic-os/setupos/BUILD.bazel index 51ee1cd63c2..865fda5038f 100644 --- a/ic-os/setupos/BUILD.bazel +++ b/ic-os/setupos/BUILD.bazel @@ -4,6 +4,7 @@ exports_files([ "partitions.csv", "grub.cfg", "config/config.ini", + "config/firewall.json", "config/node_operator_private_key.pem", "config/ssh_authorized_keys/admin", "data/deployment.json.template", diff --git a/ic-os/setupos/README.adoc b/ic-os/setupos/README.adoc index c9b4c4e0c25..db09fff1783 100644 --- a/ic-os/setupos/README.adoc +++ b/ic-os/setupos/README.adoc @@ -20,6 +20,42 @@ For more information on the onboarding and installation process, as well as the To build a SetupOS image, refer to the link:../README.adoc[IC-OS README] +=== Running SetupOS locally + +This requires QEMU with qemu-system and qemu-kvm installed on the machine. + +Bazel can be used to boot a local VM with a GuestOS like this: + + bazel run //ic-os/setupos/envs/dev:launch-local-vm + +In a VM or container without KVM support, use: + + bazel run //ic-os/setupos/envs/dev:launch-local-vm-no-kvm + +==== Testing a running SetupOS interactively + +As you know, running SetupOS will attempt setup as soon as the system boots, which will fail. +You can skip hardware and network checks, avoiding the failure, through the following kernel command line options: + +* `ic.setupos.check_hardware=0` +* `ic.setupos.check_network=0` + +These options must be added to the file `context/extra_boot_args` in this folder prior to launching. +Please do not check in such changes to this file. + +Another issue launching SetupOS this way is that, after the checks, the installation starts automatically. +This destroys the disk that SetupOS booted from. To skip setup and reboot, add the following kernel +command line option: + +* `ic.setupos.stop_before_installation`. + +All together, you can add the following options to the `extra_boot_args` file to get an interactive +setup process for testing without onscreen errors: + +``` +ic.setupos.check_hardware=0 ic.setupos.check_network=0 ic.setupos.stop_before_installation +``` + == Under the hood: Installation The SetupOS installation is initiated by the systemd service unit file `setupos.service`. This service is of type idle, which means the installation is triggered only after every other unit has either completed or started. diff --git a/ic-os/setupos/config/firewall.json b/ic-os/setupos/config/firewall.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/ic-os/setupos/config/firewall.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/ic-os/setupos/context/extra_boot_args b/ic-os/setupos/context/extra_boot_args index 31071fb3809..3fd6fe7f9fd 100644 --- a/ic-os/setupos/context/extra_boot_args +++ b/ic-os/setupos/context/extra_boot_args @@ -4,8 +4,7 @@ # useful for debug and policy development. The system behaves essentially the # same as if SELinux was not activated. # -EXTRA_BOOT_ARGS="security=selinux selinux=1 enforcing=0" - +EXTRA_BOOT_ARGS="security=selinux selinux=1 enforcing=0 ic.setupos.check_hardware=0 ic.setupos.check_network=0 ic.setupos.stop_before_installation" # Uncomment this to run system with SELinux in ENFORCING mode: All rules # of the policy are enforced, and forbidden actions are not just logged but # stopped. This causes the system to behave differently than in either diff --git a/ic-os/setupos/defs.bzl b/ic-os/setupos/defs.bzl index 325ebb995fa..03860cb932c 100644 --- a/ic-os/setupos/defs.bzl +++ b/ic-os/setupos/defs.bzl @@ -114,6 +114,7 @@ def _custom_partitions(mode): config_dict = { Label("//ic-os/setupos:config/config.ini"): "config.ini", + Label("//ic-os/setupos:config/firewall.json"): "firewall.json", Label("//ic-os/setupos:config/ssh_authorized_keys/admin"): "ssh_authorized_keys/admin", } diff --git a/rs/ic_os/config/BUILD.bazel b/rs/ic_os/config/BUILD.bazel index 2438720360d..625c7162e0f 100644 --- a/rs/ic_os/config/BUILD.bazel +++ b/rs/ic_os/config/BUILD.bazel @@ -8,6 +8,7 @@ DEPENDENCIES = [ "//rs/types/types", "@crate_index//:anyhow", "@crate_index//:clap", + "@crate_index//:ipnet", "@crate_index//:regex", "@crate_index//:serde", "@crate_index//:serde_json", @@ -18,6 +19,7 @@ DEPENDENCIES = [ DEV_DEPENDENCIES = [ # Keep sorted. "@crate_index//:once_cell", + "@crate_index//:pretty_assertions", "@crate_index//:tempfile", ] diff --git a/rs/ic_os/config/Cargo.toml b/rs/ic_os/config/Cargo.toml index 811e805c465..539e6438667 100644 --- a/rs/ic_os/config/Cargo.toml +++ b/rs/ic_os/config/Cargo.toml @@ -6,17 +6,19 @@ edition = "2021" [dependencies] anyhow = { workspace = true } ic-types = { path = "../../types/types" } +ipnet = { version = "2.8.0", features = ["serde"] } clap = { workspace = true } utils = { path = "../utils" } url = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } -serde_with = "1.6.2" +serde_with = { workspace = true } regex = { workspace = true } [dev-dependencies] once_cell = "1.8" tempfile = { workspace = true } +pretty_assertions = { workspace = true } [lib] name = "config" diff --git a/rs/ic_os/config/src/firewall_json.rs b/rs/ic_os/config/src/firewall_json.rs new file mode 100644 index 00000000000..25c514aa757 --- /dev/null +++ b/rs/ic_os/config/src/firewall_json.rs @@ -0,0 +1,197 @@ +use crate::types::firewall::FirewallRule; +use crate::types::firewall::FirewallSettings; +use anyhow::Result; +use std::error::Error; +use std::fmt::Display; +use std::fs::File; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug)] +pub enum FirewallRulesError { + IOError((PathBuf, std::io::Error)), + ParseError((PathBuf, serde_json::Error)), +} + +impl Display for FirewallRulesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FirewallRulesError::IOError((path, _)) => { + write!(f, "Cannot read file {}", path.display()) + } + FirewallRulesError::ParseError((path, _)) => { + write!( + f, + "Cannot parse file {} as a list of firewall rules", + path.display() + ) + } + } + } +} + +impl Error for FirewallRulesError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + FirewallRulesError::IOError((_, e)) => Some(e), + FirewallRulesError::ParseError((_, e)) => Some(e), + } + } +} + +/// Parse a user-supplied firewall configuration file. +/// Returns a list of firewall rules. +/// +/// The firewall configuration file format is described in document Network-Configuration.adoc. +fn get_firewall_rules_json(firewall_file: &Path) -> Result, FirewallRulesError> { + let file = match File::open(firewall_file) { + Ok(file) => file, + Err(e) => { + return Err(FirewallRulesError::IOError(( + firewall_file.to_path_buf(), + e, + ))) + } + }; + match serde_json::from_reader(&file) { + Ok(val) => Ok(val), + Err(e) => Err(FirewallRulesError::ParseError(( + firewall_file.to_path_buf(), + e, + ))), + } +} + +/// Parse an optionally explicitly specified firewall configuration file +/// falling back to a default configuration file. +/// +/// If the firewall configuration file is *not* specified, the default +/// is read. In this specific case, if the default configuration file does +/// not exist, the result value is Ok(None). +/// +/// If the firewall configuration file *is* specified, and it does not exist, +/// an Err is returned. +/// +/// Also read the documentation of get_firewall_rules_json. +pub fn get_firewall_rules_json_or_default( + firewall_file: Option<&Path>, + default_firewall_file: &Path, +) -> Result, FirewallRulesError> { + match firewall_file { + Some(firewall_file) => { + get_firewall_rules_json(firewall_file).map(|r| Some(FirewallSettings { rules: r })) + } + None => match get_firewall_rules_json(Path::new(default_firewall_file)) { + Ok(config) => Ok(Some(FirewallSettings { rules: config })), + Err(FirewallRulesError::IOError((_, e))) + if e.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } + Err(e) => Err(e), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::firewall::{FirewallRuleAction, FirewallRuleDestination}; + use std::io::Write; + use tempfile::NamedTempFile; + + fn temp_fixture(text: &str) -> Result { + let mut temp_file = NamedTempFile::new()?; + write!(temp_file, "{}", text)?; + Ok(temp_file) + } + + macro_rules! bad_rules_must_be_bad { + ($text:literal) => { + let temp_file = temp_fixture($text)?; + let outp = get_firewall_rules_json(temp_file.path()); + assert!(outp.is_err()); + Ok(()) + }; + } + + #[test] + fn test_get_firewall_rules_json() -> Result<()> { + // Test valid firewall.json. + let temp_file = temp_fixture( + "[ + { + \"from\": \"2001:db8:abcd:0012::0/64\", + \"to\": \"GuestOS\" + }, + { + \"from\": \"2001:db8:abcd:0013::0/64\", + \"to\": \"HostOS\", + \"protocol\": \"tcp\", + \"to_ports\": \"15-60\", + \"action\": \"drop\" + }, + { + \"from\": \"12.13.14.15/24\", + \"to\": \"Both\", + \"action\": \"accept\" + } +]", + )?; + let outp = get_firewall_rules_json(temp_file.path())?; + + assert_eq!(outp[0].to, FirewallRuleDestination::GuestOS); + assert_eq!(outp[1].to, FirewallRuleDestination::HostOS); + assert_eq!(outp[2].to, FirewallRuleDestination::Both); + assert_eq!(outp[0].action, FirewallRuleAction::Accept); + assert_eq!(outp[1].action, FirewallRuleAction::Drop); + assert_eq!(outp[2].action, FirewallRuleAction::Accept); + + Ok(()) + } + + #[test] + fn test_get_firewall_rules_json_port_out_of_range() -> Result<()> { + bad_rules_must_be_bad! {"[ + { + \"from\": \"2001:db8:abcd:0012::0/64\", + \"to\": \"GuestOS\" + \"to_ports\": 65537, + } +]"} + } + + #[test] + fn test_get_firewall_rules_json_port_empty() -> Result<()> { + bad_rules_must_be_bad! {"[ + { + \"from\": \"2001:db8:abcd:0012::0/64\", + \"to_ports\": \"\", + } +]"} + } + + #[test] + fn test_get_firewall_rules_json_port_incomplete() -> Result<()> { + bad_rules_must_be_bad! {"[ + { + \"from\": \"2001:db8:abcd:0012::0/64\", + \"to_ports\": \"-24\", + } +]"} + } + + #[test] + fn test_get_firewall_rules_json_empty_ip() -> Result<()> { + bad_rules_must_be_bad! {"[ + { + \"from\": \"\", + } +]"} + } + + #[test] + fn test_get_firewall_rules_json_empty_file() -> Result<()> { + bad_rules_must_be_bad! {""} + } +} diff --git a/rs/ic_os/config/src/lib.rs b/rs/ic_os/config/src/lib.rs index 2e4cf733440..612149551ff 100644 --- a/rs/ic_os/config/src/lib.rs +++ b/rs/ic_os/config/src/lib.rs @@ -1,5 +1,6 @@ pub mod config_ini; pub mod deployment_json; +pub mod firewall_json; pub mod types; use anyhow::{Context, Result}; @@ -10,6 +11,7 @@ use std::path::Path; pub static DEFAULT_SETUPOS_CONFIG_OBJECT_PATH: &str = "/var/ic/config/config.json"; pub static DEFAULT_SETUPOS_CONFIG_INI_FILE_PATH: &str = "/config/config.ini"; +pub static DEFAULT_SETUPOS_FIREWALL_JSON_FILE_PATH: &str = "/config/firewall.json"; pub static DEFAULT_SETUPOS_DEPLOYMENT_JSON_PATH: &str = "/data/deployment.json"; pub static DEFAULT_SETUPOS_NNS_PUBLIC_KEY_PATH: &str = "/data/nns_public_key.pem"; pub static DEFAULT_SETUPOS_SSH_AUTHORIZED_KEYS_PATH: &str = "/config/ssh_authorized_keys"; @@ -20,6 +22,7 @@ pub static DEFAULT_SETUPOS_HOSTOS_CONFIG_OBJECT_PATH: &str = "/var/ic/config/con pub static DEFAULT_HOSTOS_CONFIG_INI_FILE_PATH: &str = "/boot/config/config.ini"; pub static DEFAULT_HOSTOS_DEPLOYMENT_JSON_PATH: &str = "/boot/config/deployment.json"; +pub static DEFAULT_HOSTOS_FIREWALL_JSON_PATH: &str = "/boot/config/firewall.json"; pub fn serialize_and_write_config(path: &Path, config: &T) -> Result<()> { let serialized_config = @@ -62,6 +65,7 @@ mod tests { ipv4_prefix_length: None, domain: None, mgmt_mac: None, + firewall: None, }; let logging = Logging { elasticsearch_hosts: [ diff --git a/rs/ic_os/config/src/main.rs b/rs/ic_os/config/src/main.rs index ba0dde4f2b3..757c4cb3c80 100644 --- a/rs/ic_os/config/src/main.rs +++ b/rs/ic_os/config/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use config::config_ini::{get_config_ini_settings, ConfigIniSettings}; use config::deployment_json::get_deployment_settings; +use config::firewall_json::get_firewall_rules_json_or_default; use config::serialize_and_write_config; use std::fs::File; use std::path::{Path, PathBuf}; @@ -32,6 +33,9 @@ pub enum Commands { #[arg(long, default_value = config::DEFAULT_SETUPOS_CONFIG_OBJECT_PATH, value_name = "config.json")] setupos_config_json_path: PathBuf, + + #[arg(long, default_value = None, value_name = "firewall.json")] + firewall_json_path: Option, }, /// Creates HostOSConfig object from existing SetupOS config.json file GenerateHostosConfig { @@ -60,6 +64,7 @@ pub fn main() -> Result<()> { ssh_authorized_keys_path, node_operator_private_key_path, setupos_config_json_path, + firewall_json_path, }) => { // get config.ini settings let config_ini_settings = get_config_ini_settings(&config_ini_path)?; @@ -77,6 +82,12 @@ pub fn main() -> Result<()> { // get deployment.json variables let deployment_json_settings = get_deployment_settings(&deployment_json_path)?; + // get firewall.json rules + let firewall = get_firewall_rules_json_or_default( + firewall_json_path.as_ref().map(Path::new), + Path::new(config::DEFAULT_SETUPOS_FIREWALL_JSON_FILE_PATH), + )?; + let network_settings = NetworkSettings { ipv6_prefix, ipv6_prefix_length, @@ -86,6 +97,7 @@ pub fn main() -> Result<()> { ipv4_prefix_length, domain, mgmt_mac: deployment_json_settings.deployment.mgmt_mac, + firewall, }; let logging = Logging { diff --git a/rs/ic_os/config/src/types.rs b/rs/ic_os/config/src/types.rs index 5940a7dc1ea..21584a22b2d 100644 --- a/rs/ic_os/config/src/types.rs +++ b/rs/ic_os/config/src/types.rs @@ -1,11 +1,15 @@ +use crate::types::firewall::FirewallSettings; use ic_types::malicious_behaviour::MaliciousBehaviour; use serde::{Deserialize, Serialize}; use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::PathBuf; use url::Url; +pub mod firewall; + /// SetupOS configuration. User-facing configuration files /// (e.g., `config.ini`, `deployment.json`) are transformed into `SetupOSConfig`. + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct SetupOSConfig { pub network_settings: NetworkSettings, @@ -91,6 +95,8 @@ pub struct NetworkSettings { pub ipv4_prefix_length: Option, pub domain: Option, pub mgmt_mac: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub firewall: Option, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] @@ -103,6 +109,7 @@ pub struct ICOSSettings { pub hostname: String, /// This file contains the Node Operator private key, /// which is registered with the NNS and used to sign the IC join request. + // FIXME: these should probably be under its own structure Auth. pub node_operator_private_key_path: Option, /// This directory contains individual files named `admin`, `backup`, `readonly`. /// The contents of these files serve as `authorized_keys` for their respective role account. diff --git a/rs/ic_os/config/src/types/firewall.rs b/rs/ic_os/config/src/types/firewall.rs new file mode 100644 index 00000000000..31ce521d848 --- /dev/null +++ b/rs/ic_os/config/src/types/firewall.rs @@ -0,0 +1,361 @@ +use ipnet::IpNet; +use serde_json; + +use std::fmt; +use std::fmt::Display; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub enum FirewallRuleDestination { + #[serde(alias = "hostos")] + HostOS, + #[serde(alias = "guestos")] + GuestOS, + #[serde(alias = "both")] + Both, +} + +impl Default for FirewallRuleDestination { + fn default() -> Self { + Self::Both + } +} + +fn firewall_rule_destination_is_default(d: &FirewallRuleDestination) -> bool { + *d == FirewallRuleDestination::default() +} + +fn firewall_rule_protocol_is_default(d: &FirewallRuleProtocol) -> bool { + *d == FirewallRuleProtocol::default() +} + +fn firewall_rule_action_is_default(d: &FirewallRuleAction) -> bool { + *d == FirewallRuleAction::default() +} + +fn firewall_rule_comment_is_empty(d: &str) -> bool { + d.is_empty() +} + +#[derive(PartialEq, Debug, Clone)] +pub struct FirewallRulePortRange { + from: u16, + to: u16, +} + +impl FirewallRulePortRange { + pub fn as_nft_interval(&self) -> String { + match self.from == self.to { + true => format!("{}", self.from), + false => format!("{}-{}", self.from, self.to), + } + } +} + +impl<'de> serde::Deserialize<'de> for FirewallRulePortRange { + fn deserialize>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + match value { + serde_json::Value::String(s) => { + return FirewallRulePortRange::from_str(s.as_str()) + .map_err(serde::de::Error::custom) + } + serde_json::Value::Number(n) => { + let x: Option = n.as_u64(); + let xx = match x { + None => return Err(serde::de::Error::custom("Port is not a positive integer")), + Some(y) => match u16::try_from(y) { + Ok(z) => z, + Err(_) => { + return Err(serde::de::Error::custom( + "Port is not a positive integer lower than 65536", + )); + } + }, + }; + Ok(FirewallRulePortRange { from: xx, to: xx }) + } + _ => Err(serde::de::Error::custom("Invalid data type for port range")), + } + } +} + +impl serde::Serialize for FirewallRulePortRange { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.from == self.to { + serializer.serialize_u16(self.from) + } else { + serializer.serialize_str(format!("{}-{}", self.from, self.to).as_str()) + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ParsePortRangeError { + message: String, +} + +impl ParsePortRangeError { + fn new(msg: &str) -> Self { + Self { + message: msg.to_string(), + } + } +} + +impl Display for ParsePortRangeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "parse port(s) error: {}", self.message) + } +} + +impl FromStr for FirewallRulePortRange { + type Err = ParsePortRangeError; + + fn from_str(s: &str) -> Result { + if s.contains("-") { + let (x, y) = s + .trim_start_matches('(') + .trim_end_matches(')') + .split_once('-') + .ok_or(ParsePortRangeError::new( + format!( + "port range {} is not a valid integer port range delimited by a dash", + s + ) + .as_str(), + ))?; + let x_fromstr = x.parse::().map_err(|_| { + ParsePortRangeError::new("lower port bound is not a positive integer below 65536") + })?; + let y_fromstr = y.parse::().map_err(|_| { + ParsePortRangeError::new("upper port bound is not a positive integer below 65536") + })?; + if x_fromstr > y_fromstr { + return Err(ParsePortRangeError::new( + "lower port bound is greater than upper port bound", + )); + } + Ok(FirewallRulePortRange { + from: x_fromstr, + to: y_fromstr, + }) + } else { + let x_fromstr = s.parse::().map_err(|_| { + ParsePortRangeError::new("port is not a positive integer below 65536") + })?; + Ok(FirewallRulePortRange { + from: x_fromstr, + to: x_fromstr, + }) + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +#[serde(rename_all = "lowercase")] +pub enum FirewallRuleProtocol { + TCP, + UDP, + All, +} + +impl FirewallRuleProtocol { + pub fn name(&self) -> &str { + match self { + Self::TCP => "tcp", + Self::UDP => "udp", + Self::All => "all", + } + } +} + +impl Default for FirewallRuleProtocol { + fn default() -> Self { + Self::All + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum FirewallRuleAction { + Accept, + Drop, +} + +impl Default for FirewallRuleAction { + fn default() -> Self { + Self::Accept + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct FirewallRule { + pub from: IpNet, + #[serde(default, skip_serializing_if = "firewall_rule_destination_is_default")] + pub to: FirewallRuleDestination, + #[serde(default, skip_serializing_if = "firewall_rule_protocol_is_default")] + pub protocol: FirewallRuleProtocol, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_ports: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_ports: Option, + #[serde(default, skip_serializing_if = "firewall_rule_action_is_default")] + pub action: FirewallRuleAction, + #[serde(default, skip_serializing_if = "firewall_rule_comment_is_empty")] + pub comment: String, +} + +impl FirewallRule { + fn as_nftables( + &self, + destination: &FirewallRuleDestination, + ip_version: &str, + ) -> Option { + if (self.to == *destination || self.to == FirewallRuleDestination::Both) + && (match self.from { + IpNet::V6(_) => ip_version == "ip6", + IpNet::V4(_) => ip_version == "ip", + }) + { + Some(format!( + "{}\t\t{} saddr {} ct state new{} {}", + match self.comment.as_str() { + "" => "".to_string(), + &_ => self + .comment + .split("\n") + .map(|c| format!("\t\t# {}\n", c)) + .collect::>() + .join(""), + }, + ip_version, + self.from, + match self.protocol { + FirewallRuleProtocol::All => "".to_string(), + _ => format!( + " {}{}{}{}", + self.protocol.name(), + match &self.from_ports { + None => "".to_string(), + Some(s) => format!(" sport {}", s.as_nft_interval()), + }, + match &self.to_ports { + None => "".to_string(), + Some(s) => format!(" dport {}", s.as_nft_interval()), + }, + match (&self.from_ports, &self.to_ports) { + (None, None) => " flags syn".to_string(), + _ => "".to_string(), + }, + ), + }, + match self.action { + FirewallRuleAction::Accept => "accept", + FirewallRuleAction::Drop => "drop", + } + )) + } else { + None + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct FirewallSettings { + pub rules: Vec, +} + +impl FirewallSettings { + /// Render a list of firewall rules to an NFT script. + /// + /// In the generated script, the chains are first created, then flushed, + /// then filled, such that loading them (which is atomic) can never fail. + pub fn as_nftables(&self, destination: &FirewallRuleDestination) -> String { + ["ip", "ip6"] + .into_iter() + .map(|t| { + format!( + "# Create the provider_INPUT chain. +table {} filter {{ +\tchain provider_INPUT {{ +\t}} +}} + +# Flush the provider_INPUT chain. +flush chain {} filter provider_INPUT + +# Fill the provider_INPUT chain. +table {} filter {{ +\tchain provider_INPUT {{ +{}\t}} +}}", + t, + t, + t, + self.rules + .iter() + .filter_map(|r| r.as_nftables(destination, t)) + .map(|text| format!("{}\n", text)) + .collect::>() + .join(""), + ) + }) + .collect::>() + .join("\n") + } +} +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_serialize_and_deserialize_firewall() { + let (inp, exp) = ( + "[ + { + \"from\": \"2001:db8:abcd:0012::0/64\", + \"to\": \"GuestOS\" + }, + { + \"from\": \"2001:db8:abcd:0013::0/64\", + \"to\": \"HostOS\", + \"protocol\": \"tcp\", + \"to_ports\": \"15-60\", + \"action\": \"drop\" + }, + { + \"from\": \"12.13.14.15/24\", + \"to\": \"HostOS\", + \"action\": \"accept\" + } +]", + "[ + { + \"from\": \"2001:db8:abcd:12::/64\", + \"to\": \"GuestOS\" + }, + { + \"from\": \"2001:db8:abcd:13::/64\", + \"to\": \"HostOS\", + \"protocol\": \"tcp\", + \"to_ports\": \"15-60\", + \"action\": \"drop\" + }, + { + \"from\": \"12.13.14.15/24\", + \"to\": \"HostOS\" + } +]", + ); + let firewall_settings: Vec = serde_json::from_str(inp).unwrap(); + let outp = serde_json::to_string_pretty(&firewall_settings).unwrap(); + assert_eq!(exp, outp); + } +} diff --git a/rs/ic_os/dev_test_tools/setupos-inject-configuration/src/main.rs b/rs/ic_os/dev_test_tools/setupos-inject-configuration/src/main.rs index 206cfdb0bd6..70c67b68c37 100644 --- a/rs/ic_os/dev_test_tools/setupos-inject-configuration/src/main.rs +++ b/rs/ic_os/dev_test_tools/setupos-inject-configuration/src/main.rs @@ -34,6 +34,9 @@ struct Cli { #[command(flatten)] deployment: DeploymentConfig, + + #[command(flatten)] + firewall: FirewallConfig, } #[derive(Args)] @@ -79,6 +82,15 @@ struct DeploymentConfig { mgmt_mac: Option, } +#[derive(Args)] +struct FirewallConfig { + #[arg(long)] + /// Optional text to place into SetupOS' firewall.json alongside config.ini + /// If unspecified, an empty rule list is injected. + /// See Network-Configuration.adoc for format. + firewall_json: Option, +} + #[tokio::main] async fn main() -> Result<(), Error> { let cli = Cli::parse(); @@ -115,6 +127,21 @@ async fn main() -> Result<(), Error> { .context("failed to copy private-key")?; } + // Update firewall rules + // If no firewall rules are provided, we inject an empty set + // These rules go alongside config.ini + let firewall_text = match cli.firewall.firewall_json { + Some(text) => text, + None => "[]".to_string(), + }; + let src = NamedTempFile::new()?; + write!(&src, "{firewall_text}")?; + fs::set_permissions(src.path(), Permissions::from_mode(0o644))?; + config + .write_file(src.path(), Path::new("/firewall.json")) + .await + .context("failed to write firewall JSON")?; + // Print previous public keys println!("Previous ssh_authorized_keys/admin:\n---"); let previous_admin_keys = config diff --git a/rs/ic_os/os_tools/guestos_tool/src/generate_network_config.rs b/rs/ic_os/os_tools/guestos_tool/src/generate_network_config.rs index 0f4eb2b2cb3..b9fb278e5af 100644 --- a/rs/ic_os/os_tools/guestos_tool/src/generate_network_config.rs +++ b/rs/ic_os/os_tools/guestos_tool/src/generate_network_config.rs @@ -13,6 +13,7 @@ use utils::get_command_stdout; use network::systemd::IPV6_NAME_SERVER_NETWORKD_CONTENTS; pub static DEFAULT_GUESTOS_NETWORK_CONFIG_PATH: &str = "/boot/config/network.conf"; +pub static DEFAULT_GUESTOS_FIREWALL_JSON_PATH: &str = "/boot/config/firewall.json"; const IPV4_NAME_SERVER_NETWORKD_CONTENTS: &str = "DNS=1.1.1.1\nDNS=1.0.0.1\nDNS=8.8.8.8\nDNS=8.8.4.4\n"; diff --git a/rs/ic_os/os_tools/guestos_tool/src/main.rs b/rs/ic_os/os_tools/guestos_tool/src/main.rs index d3206585e17..9040ea13118 100644 --- a/rs/ic_os/os_tools/guestos_tool/src/main.rs +++ b/rs/ic_os/os_tools/guestos_tool/src/main.rs @@ -9,10 +9,13 @@ use node_gen::get_node_gen_metric; mod prometheus_metric; use prometheus_metric::write_single_metric; +use config::firewall_json; +use config::types::firewall; + mod generate_network_config; use generate_network_config::{ generate_networkd_config, validate_and_construct_ipv4_address_info, - DEFAULT_GUESTOS_NETWORK_CONFIG_PATH, + DEFAULT_GUESTOS_FIREWALL_JSON_PATH, DEFAULT_GUESTOS_NETWORK_CONFIG_PATH, }; use network::systemd::{restart_systemd_networkd, DEFAULT_SYSTEMD_NETWORK_DIR}; @@ -61,6 +64,15 @@ pub enum Commands { /// Fails if directory doesn't exist. output_path: String, }, + RenderFirewallConfig { + #[arg(index = 1)] + /// Path to firewall.json. Defaults to DEFAULT_GUESTOS_FIREWALL_JSON_PATH if unspecified. + /// If the option is not specified, and the default file does not exist, it renders an + /// empty firewall ruleset. If the option is specified, and the file does not exist, + /// it will raise an error. If the file exists but the rules cannot be read, it will + /// raise an error. + firewall_file: Option, + }, } #[derive(Parser)] @@ -114,6 +126,28 @@ pub fn main() -> Result<()> { Ok(()) } + Some(Commands::RenderFirewallConfig { firewall_file }) => { + let config = firewall_json::get_firewall_rules_json_or_default( + firewall_file.as_ref().map(Path::new), + Path::new(DEFAULT_GUESTOS_FIREWALL_JSON_PATH), + )?; + eprintln!( + "Firewall config ({}): {:#?}", + match firewall_file { + Some(f) => format!("from explicitly specified {}", f), + None => format!("from default {}", DEFAULT_GUESTOS_FIREWALL_JSON_PATH), + }, + config + ); + println!( + "{}", + match config { + Some(c) => c.as_nftables(&firewall::FirewallRuleDestination::GuestOS), + None => "".to_string(), + }, + ); + Ok(()) + } None => Ok(()), } } diff --git a/rs/ic_os/os_tools/hostos_tool/Cargo.toml b/rs/ic_os/os_tools/hostos_tool/Cargo.toml index 08ba6eea667..3a711c94027 100644 --- a/rs/ic_os/os_tools/hostos_tool/Cargo.toml +++ b/rs/ic_os/os_tools/hostos_tool/Cargo.toml @@ -12,4 +12,4 @@ anyhow = { workspace = true } clap = { workspace = true } config = { path = "../../config" } network = { path = "../../network" } -utils = { path = "../../utils" } \ No newline at end of file +utils = { path = "../../utils" } diff --git a/rs/ic_os/os_tools/hostos_tool/src/main.rs b/rs/ic_os/os_tools/hostos_tool/src/main.rs index 3ed2ab183b3..cb399f0bfe5 100644 --- a/rs/ic_os/os_tools/hostos_tool/src/main.rs +++ b/rs/ic_os/os_tools/hostos_tool/src/main.rs @@ -5,7 +5,12 @@ use clap::{Parser, Subcommand}; use config::config_ini::config_map_from_path; use config::deployment_json::get_deployment_settings; -use config::{DEFAULT_HOSTOS_CONFIG_INI_FILE_PATH, DEFAULT_HOSTOS_DEPLOYMENT_JSON_PATH}; +use config::firewall_json; +use config::types::firewall; +use config::{ + DEFAULT_HOSTOS_CONFIG_INI_FILE_PATH, DEFAULT_HOSTOS_DEPLOYMENT_JSON_PATH, + DEFAULT_HOSTOS_FIREWALL_JSON_PATH, +}; use network::generate_network_config; use network::info::NetworkInfo; use network::ipv6::generate_ipv6_address; @@ -30,6 +35,15 @@ pub enum Commands { #[arg(short, long, default_value = "HostOS")] node_type: String, }, + RenderFirewallConfig { + #[arg(index = 1)] + /// Path to firewall.json. Defaults to DEFAULT_HOSTOS_FIREWALL_JSON_PATH if unspecified. + /// If the option is not specified, and the default file does not exist, it renders an + /// empty firewall ruleset. If the option is specified, and the file does not exist, + /// it will raise an error. If the file exists but the rules cannot be read, it will + /// raise an error. + firewall_file: Option, + }, } #[derive(Parser)] @@ -168,6 +182,28 @@ pub fn main() -> Result<()> { println!("{}", generated_mac); Ok(()) } + Some(Commands::RenderFirewallConfig { firewall_file }) => { + let config = firewall_json::get_firewall_rules_json_or_default( + firewall_file.as_ref().map(Path::new), + Path::new(DEFAULT_HOSTOS_FIREWALL_JSON_PATH), + )?; + eprintln!( + "Firewall config ({}): {:#?}", + match firewall_file { + Some(f) => format!("from explicitly specified {}", f), + None => format!("from default {}", DEFAULT_HOSTOS_FIREWALL_JSON_PATH), + }, + config + ); + println!( + "{}", + match config { + Some(c) => c.as_nftables(&firewall::FirewallRuleDestination::HostOS), + None => "".to_string(), + }, + ); + Ok(()) + } None => Err(anyhow!( "No subcommand specified. Run with '--help' for subcommands" )), diff --git a/rs/ic_os/os_tools/setupos_tool/src/main.rs b/rs/ic_os/os_tools/setupos_tool/src/main.rs index dd929f842cf..626c68f3bad 100644 --- a/rs/ic_os/os_tools/setupos_tool/src/main.rs +++ b/rs/ic_os/os_tools/setupos_tool/src/main.rs @@ -5,7 +5,11 @@ use clap::{Parser, Subcommand}; use config::config_ini::config_map_from_path; use config::deployment_json::get_deployment_settings; -use config::{DEFAULT_SETUPOS_CONFIG_INI_FILE_PATH, DEFAULT_SETUPOS_DEPLOYMENT_JSON_PATH}; +use config::firewall_json; +use config::{ + DEFAULT_SETUPOS_CONFIG_INI_FILE_PATH, DEFAULT_SETUPOS_DEPLOYMENT_JSON_PATH, + DEFAULT_SETUPOS_FIREWALL_JSON_FILE_PATH, +}; use network::generate_network_config; use network::info::NetworkInfo; use network::ipv6::generate_ipv6_address; @@ -26,6 +30,15 @@ pub enum Commands { #[arg(short, long, default_value = "SetupOS")] node_type: String, }, + CheckFirewallConfig { + #[arg(index = 1)] + /// Path to firewall.json. Defaults to DEFAULT_SETUPOS_FIREWALL_JSON_FILE_PATH if unspecified. + /// If the option is not specified, and the default file does not exist, it renders an + /// empty firewall ruleset. If the option is specified, and the file does not exist, + /// it will raise an error. If the file exists but the rules cannot be read, it will + /// raise an error. + firewall_file: Option, + }, } #[derive(Parser)] @@ -125,6 +138,21 @@ pub fn main() -> Result<()> { println!("{}", to_cidr(ipv6_address, network_info.ipv6_subnet)); Ok(()) } + Some(Commands::CheckFirewallConfig { firewall_file }) => { + let _config_ = firewall_json::get_firewall_rules_json_or_default( + firewall_file.as_ref().map(Path::new), + Path::new(DEFAULT_SETUPOS_FIREWALL_JSON_FILE_PATH), + )?; + eprintln!( + "Firewall config checks out {}", + match firewall_file { + Some(f) => format!("from explicitly specified {}", f), + None => format!("from default {}", DEFAULT_SETUPOS_FIREWALL_JSON_FILE_PATH), + }, + ); + Ok(()) + } + None => Err(anyhow!( "No subcommand specified. Run with '--help' for subcommands" )), diff --git a/rs/orchestrator/testdata/nftables_assigned_replica.conf.golden b/rs/orchestrator/testdata/nftables_assigned_replica.conf.golden index ca8ef8781e0..e3b801825df 100644 --- a/rs/orchestrator/testdata/nftables_assigned_replica.conf.golden +++ b/rs/orchestrator/testdata/nftables_assigned_replica.conf.golden @@ -14,6 +14,9 @@ table filter { counter rate_limit_v4_counter {} counter connection_limit_v4_counter {} + chain provider_INPUT { + } + chain INPUT { type filter hook input priority 0; policy drop; iif lo accept @@ -34,6 +37,7 @@ ip saddr {6.6.6.6} ct state { new } tcp dport {1006} accept # global ct state { invalid } drop # - The rule accepts all established and related connections. It's required for the IPv4 connectivity check. ct state { established, related } accept + jump provider_INPUT log prefix "Drop - default policy: " } @@ -64,6 +68,9 @@ table ip6 filter { counter rate_limit_v6_counter {} counter connection_limit_v6_counter {} + chain provider_INPUT { + } + chain INPUT { type filter hook input priority 0; policy drop; iif lo accept @@ -89,6 +96,7 @@ ip6 saddr {::ffff:3.3.3.3} ct state { new } tcp dport {1003} accept # subnet_ynd ip6 saddr {::ffff:4.4.4.4} ct state { new } tcp dport {1004} accept # replica_nodes ip6 saddr {::ffff:6.6.6.6} ct state { new } tcp dport {1006} accept # global + jump provider_INPUT log prefix "Drop - default policy: " } diff --git a/rs/registry/canister/Cargo.toml b/rs/registry/canister/Cargo.toml index 25ad4b78899..fb4bd24db93 100644 --- a/rs/registry/canister/Cargo.toml +++ b/rs/registry/canister/Cargo.toml @@ -45,7 +45,7 @@ ic-types = { path = "../../types/types" } idna = { workspace = true } lazy_static = { workspace = true } leb128 = "0.2.4" -ipnet = "2.5.0" +ipnet = "2.8.0" on_wire = { path = "../../rust_canisters/on_wire" } prost = { workspace = true } serde = { workspace = true }