From c874b028b743381c56779c4d519ac1cdd4f94b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juho=20Yrj=C3=B6l=C3=A4?= Date: Tue, 17 Feb 2026 16:49:43 +0200 Subject: [PATCH 1/4] charts: allow to define ports when service type is NodePort --- .../bitcoincore/charts/cln/templates/service.yaml | 9 +++++++++ .../charts/bitcoincore/charts/cln/values.yaml | 4 ++++ .../bitcoincore/charts/lnd/templates/service.yaml | 12 ++++++++++++ .../charts/bitcoincore/charts/lnd/values.yaml | 5 +++++ .../charts/bitcoincore/templates/service.yaml | 15 +++++++++++++++ resources/charts/bitcoincore/values.yaml | 6 ++++++ 6 files changed, 51 insertions(+) diff --git a/resources/charts/bitcoincore/charts/cln/templates/service.yaml b/resources/charts/bitcoincore/charts/cln/templates/service.yaml index 565f50182..2e6a911c0 100644 --- a/resources/charts/bitcoincore/charts/cln/templates/service.yaml +++ b/resources/charts/bitcoincore/charts/cln/templates/service.yaml @@ -12,13 +12,22 @@ spec: targetPort: p2p protocol: TCP name: p2p + {{- if and (eq .Values.service.type "NodePort") (.Values.service.p2pNodePort) }} + nodePort: {{ .Values.service.p2pNodePort }} + {{- end }} - port: {{ .Values.RPCPort }} targetPort: rpc protocol: TCP name: rpc + {{- if and (eq .Values.service.type "NodePort") (.Values.service.rpcNodePort) }} + nodePort: {{ .Values.service.rpcNodePort }} + {{- end }} - port: {{ .Values.RestPort }} targetPort: rest protocol: TCP name: rest + {{- if and (eq .Values.service.type "NodePort") (.Values.service.restNodePort) }} + nodePort: {{ .Values.service.restNodePort }} + {{- end }} selector: {{- include "cln.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/bitcoincore/charts/cln/values.yaml b/resources/charts/bitcoincore/charts/cln/values.yaml index 953280445..ae7d1a510 100644 --- a/resources/charts/bitcoincore/charts/cln/values.yaml +++ b/resources/charts/bitcoincore/charts/cln/values.yaml @@ -22,6 +22,10 @@ securityContext: {} service: type: ClusterIP + # NodePorts are only used if service.type is NodePort, values must be in the range 30000-32767 + p2pNodePort: + rpcNodePort: + restNodePort: P2PPort: 9735 RPCPort: 9736 diff --git a/resources/charts/bitcoincore/charts/lnd/templates/service.yaml b/resources/charts/bitcoincore/charts/lnd/templates/service.yaml index aecf301fe..3a7492ad1 100644 --- a/resources/charts/bitcoincore/charts/lnd/templates/service.yaml +++ b/resources/charts/bitcoincore/charts/lnd/templates/service.yaml @@ -12,19 +12,31 @@ spec: targetPort: rpc protocol: TCP name: rpc + {{- if and (eq .Values.service.type "NodePort") (.Values.service.rpcNodePort) }} + nodePort: {{ .Values.service.rpcNodePort }} + {{- end }} - port: {{ .Values.P2PPort }} targetPort: p2p protocol: TCP name: p2p + {{- if and (eq .Values.service.type "NodePort") (.Values.service.p2pNodePort) }} + nodePort: {{ .Values.service.p2pNodePort }} + {{- end }} - port: {{ .Values.RestPort }} targetPort: rest protocol: TCP name: rest + {{- if and (eq .Values.service.type "NodePort") (.Values.service.restNodePort) }} + nodePort: {{ .Values.service.restNodePort }} + {{- end }} {{- if .Values.metricsExport }} - port: {{ .Values.prometheusMetricsPort }} targetPort: prom-metrics protocol: TCP name: prometheus-metrics + {{- if and (eq .Values.service.type "NodePort") (.Values.service.prometheusNodePort) }} + nodePort: {{ .Values.service.prometheusNodePort }} + {{- end }} {{- end }} selector: {{- include "lnd.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/bitcoincore/charts/lnd/values.yaml b/resources/charts/bitcoincore/charts/lnd/values.yaml index a97e9470d..06de17af4 100644 --- a/resources/charts/bitcoincore/charts/lnd/values.yaml +++ b/resources/charts/bitcoincore/charts/lnd/values.yaml @@ -32,6 +32,11 @@ securityContext: {} service: type: ClusterIP + # NodePorts are only used if service.type is NodePort, values must be in the range 30000-32767 + p2pNodePort: + rpcNodePort: + restNodePort: + prometheusNodePort: RPCPort: 10009 P2PPort: 9735 diff --git a/resources/charts/bitcoincore/templates/service.yaml b/resources/charts/bitcoincore/templates/service.yaml index 8d8fa5324..4ca3db641 100644 --- a/resources/charts/bitcoincore/templates/service.yaml +++ b/resources/charts/bitcoincore/templates/service.yaml @@ -12,21 +12,36 @@ spec: targetPort: rpc protocol: TCP name: rpc + {{- if and (eq .Values.service.type "NodePort") (.Values.service.rpcNodePort) }} + nodePort: {{ .Values.service.rpcNodePort }} + {{- end }} - port: {{ index .Values.global .Values.global.chain "P2PPort" }} targetPort: p2p protocol: TCP name: p2p + {{- if and (eq .Values.service.type "NodePort") (.Values.service.p2pNodePort) }} + nodePort: {{ .Values.service.p2pNodePort }} + {{- end }} - port: {{ .Values.global.ZMQTxPort }} targetPort: zmq-tx protocol: TCP name: zmq-tx + {{- if and (eq .Values.service.type "NodePort") (.Values.service.zmqTxNodePort) }} + nodePort: {{ .Values.service.zmqTxNodePort }} + {{- end }} - port: {{ .Values.global.ZMQBlockPort }} targetPort: zmq-block protocol: TCP name: zmq-block + {{- if and (eq .Values.service.type "NodePort") (.Values.service.zmqBlockNodePort) }} + nodePort: {{ .Values.service.zmqBlockNodePort }} + {{- end }} - port: {{ .Values.prometheusMetricsPort }} targetPort: prom-metrics protocol: TCP name: prometheus-metrics + {{- if and (eq .Values.service.type "NodePort") (.Values.service.prometheusNodePort) }} + nodePort: {{ .Values.service.prometheusNodePort }} + {{- end }} selector: {{- include "bitcoincore.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/bitcoincore/values.yaml b/resources/charts/bitcoincore/values.yaml index 92416f6bb..5b8f486b9 100644 --- a/resources/charts/bitcoincore/values.yaml +++ b/resources/charts/bitcoincore/values.yaml @@ -32,6 +32,12 @@ securityContext: {} service: type: ClusterIP + # NodePorts are only used if service.type is NodePort, values must be in the range 30000-32767 + p2pNodePort: + rpcNodePort: + zmqTxNodePort: + zmqBlockNodePort: + prometheusNodePort: ingress: enabled: false From 66763777a77e3c1bd9c3e2bc863e77a5fde67979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juho=20Yrj=C3=B6l=C3=A4?= Date: Wed, 18 Feb 2026 13:50:33 +0200 Subject: [PATCH 2/4] docs: Documentation about connecting from host --- docs/connection-from-host.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/connection-from-host.md diff --git a/docs/connection-from-host.md b/docs/connection-from-host.md new file mode 100644 index 000000000..84eec198f --- /dev/null +++ b/docs/connection-from-host.md @@ -0,0 +1,29 @@ +# Connection from host + +### Using NodePort + +To connect to a node from your host machine, you can use the NodePort service type. This exposes the node's desired ports on the host machine, allowing you to connect to them directly. + +For example to connect to a Bitcoin Core node using the RPC port, add this to the node's configuration in the network graph definition: + +```yaml +nodes: + - name: tank-0001 + service: + type: NodePort + rpcNodePort: 30443 +``` + +Then you can connect to the node with `localhost:30443`. Or in non-local cluster `:30443`. + +All the different port options can be seen in values.yaml files. The exposed port values must be in the range 30000-32767. If left empty, a random port in that range will be assigned by Kubernetes. + +To check which ports are open on the host machine, use `kubectl get svc -n ` and look for the `PORT(S)` column. + +### Using port-forward + +Alternatively, you can use `kubectl port-forward` command. For example to expose the regtest RPC port of a Bitcoin Core node, run the below. The first port is the local port on your machine, and the second port is the port inside the cluster. You can choose any available local port. + +```shell +kubectl port-forward pod/tank-0001 18443:18443 +``` \ No newline at end of file From 8bbab47d91a9d5c4492f3938f0cd4baa7692ca59 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 20 Apr 2026 13:14:19 -0400 Subject: [PATCH 3/4] scenarios: try/catch during ln_init to prevent infinite failure loop --- resources/scenarios/commander.py | 34 ++++++++++++++++---------- resources/scenarios/ln_framework/ln.py | 6 ++++- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index a5129dbd9..0ed8da9b1 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -90,6 +90,12 @@ if "mission" not in pod.metadata.labels: continue + pod_ip = pod.status.pod_ip + while pod_ip is None: + sleep(5) + pod = sclient.read_namespaced_pod(pod.metadata.name, pod.metadata.namespace) + pod_ip = pod.status.pod_ip + if pod.metadata.labels["mission"] == "tank": WARNET["tanks"].append( { @@ -97,7 +103,7 @@ "namespace": pod.metadata.namespace, "chain": pod.metadata.labels["chain"], "p2pport": int(pod.metadata.labels["P2PPort"]), - "rpc_host": pod.status.pod_ip, + "rpc_host": pod_ip, "rpc_port": int(pod.metadata.labels["RPCPort"]), "rpc_user": "user", "rpc_password": pod.metadata.labels["rpcpassword"], @@ -110,7 +116,7 @@ lnnode = LND( pod.metadata.name, pod.metadata.namespace, - pod.status.pod_ip, + pod_ip, pod.metadata.annotations["adminMacaroon"], ) if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: @@ -196,17 +202,19 @@ def b64_to_hex(b64, reverse=False): def wait_for_tanks_connected(self): def tank_connected(self, tank): while True: - peers = tank.getpeerinfo() - count = sum( - 1 - for peer in peers - if peer.get("connection_type") == "manual" or peer.get("addnode") is True - ) - self.log.info(f"Tank {tank.tank} connected to {count}/{tank.init_peers} peers") - if count >= tank.init_peers: - break - else: - sleep(5) + try: + peers = tank.getpeerinfo() + count = sum( + 1 + for peer in peers + if peer.get("connection_type") == "manual" or peer.get("addnode") is True + ) + self.log.info(f"Tank {tank.tank} connected to {count}/{tank.init_peers} peers") + if count >= tank.init_peers: + break + except Exception as e: + self.log.warning(f"Couldn't get peer info from {tank.tank} : {e}") + sleep(5) conn_threads = [ threading.Thread(target=tank_connected, args=(self, tank)) for tank in self.nodes diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 639bb1813..4128733a8 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -208,7 +208,11 @@ def post(self, uri, data=None): def createrune(self): while True: - response = requests.get(f"http://{self.ip_address}:8080/rune.json", timeout=5).text + response = None + try: + response = requests.get(f"http://{self.ip_address}:8080/rune.json", timeout=5).text + except Exception as e: + self.log.warning(f"Error requesting /rune.json: {e}") if not response: self.log.warning(f"Unable to fetch rune from {self.name}, retrying in 2 seconds...") sleep(2) From 7a9e6d050c3b1b6727e51b9745b774bd9a4f6d26 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 20 Apr 2026 13:27:52 -0400 Subject: [PATCH 4/4] test: cover nodeport --- docs/connection-from-host.md | 54 ++++++++++++++++++++++++++++++++++-- src/warnet/dashboard.py | 8 +++++- src/warnet/k8s.py | 30 +++++++++++++++++++- src/warnet/main.py | 3 +- test/data/ln/network.yaml | 9 ++++++ test/ln_basic_test.py | 47 ++++++++++++++++++++++++++++++- 6 files changed, 145 insertions(+), 6 deletions(-) diff --git a/docs/connection-from-host.md b/docs/connection-from-host.md index 84eec198f..036fb9d4d 100644 --- a/docs/connection-from-host.md +++ b/docs/connection-from-host.md @@ -14,7 +14,57 @@ nodes: rpcNodePort: 30443 ``` -Then you can connect to the node with `localhost:30443`. Or in non-local cluster `:30443`. +To get the IP address of a node in your cluster, execute `warnet host`: + +```shell +# Minikube on Linux +> warnet host +192.168.49.2 + +# Docker Desktop on MacOS +> warnet host +kubernetes.docker.internal + +# Remote cluster +> warnet host +159.223.123.163 +``` +Then you can connect to the NodePort in the cluster `:30443`. + +> [!WARNING] +> If you are using MiniKube on MacOS, you must rely on `minikube service` to get both hostname and port for your tank: + +``` +(.venv) --> minikube service tank-0001 +|-----------|-----------|-------------------------|---------------------------| +| NAMESPACE | NAME | TARGET PORT | URL | +|-----------|-----------|-------------------------|---------------------------| +| default | tank-0001 | rpc/18443 p2p/18444 | http://192.168.49.2:30002 | +| | | zmq-tx/28333 | http://192.168.49.2:30984 | +| | | zmq-block/28332 | http://192.168.49.2:30682 | +| | | prometheus-metrics/9332 | http://192.168.49.2:30230 | +| | | | http://192.168.49.2:30175 | +|-----------|-----------|-------------------------|---------------------------| +🏃 Starting tunnel for service tank-0001. +|-----------|-----------|-------------|------------------------| +| NAMESPACE | NAME | TARGET PORT | URL | +|-----------|-----------|-------------|------------------------| +| default | tank-0001 | | http://127.0.0.1:62254 | +| | | | http://127.0.0.1:62255 | +| | | | http://127.0.0.1:62256 | +| | | | http://127.0.0.1:62257 | +| | | | http://127.0.0.1:62258 | +|-----------|-----------|-------------|------------------------| +[default tank-0001 http://127.0.0.1:62254 +http://127.0.0.1:62255 +http://127.0.0.1:62256 +http://127.0.0.1:62257 +http://127.0.0.1:62258] +❗ Because you are using a Docker driver on darwin, the terminal needs to be open to run it. +``` + + + All the different port options can be seen in values.yaml files. The exposed port values must be in the range 30000-32767. If left empty, a random port in that range will be assigned by Kubernetes. @@ -22,7 +72,7 @@ To check which ports are open on the host machine, use `kubectl get svc -n InternalIP (common) > Hostname + return addresses.get("ExternalIP") or addresses.get("InternalIP") or addresses.get("Hostname") + + def pod_log( pod_name, container_name=None, follow=False, namespace: Optional[str] = None, tail_lines=None ): diff --git a/src/warnet/main.py b/src/warnet/main.py index 768a82f96..6b8682ee3 100644 --- a/src/warnet/main.py +++ b/src/warnet/main.py @@ -3,7 +3,7 @@ from .admin import admin from .bitcoin import bitcoin from .control import down, logs, run, snapshot, stop -from .dashboard import dashboard +from .dashboard import dashboard, host from .deploy import deploy from .graph import create, graph, import_network from .image import image @@ -68,6 +68,7 @@ def version() -> None: cli.add_command(down) cli.add_command(dashboard) cli.add_command(graph) +cli.add_command(host) cli.add_command(import_network) cli.add_command(image) cli.add_command(init) diff --git a/test/data/ln/network.yaml b/test/data/ln/network.yaml index 953080d8a..42d7213ef 100644 --- a/test/data/ln/network.yaml +++ b/test/data/ln/network.yaml @@ -10,10 +10,16 @@ nodes: cln: persistence: enabled: true + service: + type: NodePort + restNodePort: 30001 - name: tank-0001 addnode: - tank-0002 + service: + type: NodePort + rpcNodePort: 30002 - name: tank-0002 addnode: @@ -23,6 +29,9 @@ nodes: lnd: persistence: enabled: true + service: + type: NodePort + restNodePort: 30003 - name: tank-0003 addnode: diff --git a/test/ln_basic_test.py b/test/ln_basic_test.py index d42c5aff6..b5976d8f6 100755 --- a/test/ln_basic_test.py +++ b/test/ln_basic_test.py @@ -8,9 +8,11 @@ from time import sleep import pexpect +import requests +from requests.auth import HTTPBasicAuth from test_base import TestBase -from warnet.process import stream_command +from warnet.process import run_command, stream_command class LNBasicTest(TestBase): @@ -56,6 +58,9 @@ def run_test(self): # Test data persistence self.test_data_persistence() + + # Check NodePorts feature + self.test_nodeports() finally: self.cleanup() @@ -261,6 +266,46 @@ def test_data_persistence(self): second = self.get_ln_node_state(ln) assert first[ln] != second, first.items() + def test_nodeports(self): + self.log.info("Testing local access via NodePort") + + if sys.platform.lower() == "darwin": + cmd = "kubectl config current-context" + if run_command(cmd).strip().lower() == "minikube": + self.log.warning( + "Skipping test: MiniKube on MacOS requires external tunnels for NodePort" + ) + return + + host = self.warnet("host") + + tank0000_cln_rest = requests.post( + f"https://{host}:30001/v1/newaddr", + json={"addresstype": "p2tr"}, + verify=False, # equivalent to curl --insecure + ) + assert "code" in tank0000_cln_rest.json() + assert "data" in tank0000_cln_rest.json() + assert "message" in tank0000_cln_rest.json() + + tank0001_bitcoin_rpc = requests.post( + f"http://{host}:30002/", + json={"method": "getblockcount"}, + auth=HTTPBasicAuth("user", "gn0cchi"), + ) + assert "result" in tank0001_bitcoin_rpc.json() + assert "error" in tank0001_bitcoin_rpc.json() + assert "id" in tank0001_bitcoin_rpc.json() + + tank0002_lnd_rest = requests.get( + f"https://{host}:30003/v1/newaddress", + params={"type": "TAPROOT_PUBKEY"}, + verify=False, # equivalent to curl --insecure + ) + assert "code" in tank0002_lnd_rest.json() + assert "message" in tank0002_lnd_rest.json() + assert "details" in tank0002_lnd_rest.json() + if __name__ == "__main__": test = LNBasicTest()