diff --git a/resources/networks/hello/network.yaml b/resources/networks/hello/network.yaml index f5acf0a83..75204a641 100644 --- a/resources/networks/hello/network.yaml +++ b/resources/networks/hello/network.yaml @@ -67,6 +67,11 @@ plugins: # Each plugin section has a number of hooks available (preDeploy, post simln: # You can have multiple plugins per hook entrypoint: "../../plugins/simln" activity: '[{"source": "tank-0003-ln", "destination": "tank-0005-ln", "interval_secs": 1, "amount_msat": 2000}]' + circuitbreaker: + entrypoint: "../../plugins/circuitbreaker" + podName: "circuitbreaker-pod" + rpcserver: "172.29.34.166:10009" + httplisten: "0.0.0.0:9235" preNode: # preNode plugins run before each node is deployed hello: entrypoint: "../../plugins/hello" @@ -84,4 +89,4 @@ plugins: # Each plugin section has a number of hooks available (preDeploy, post hello: entrypoint: "../../plugins/hello" helloTo: "postNetwork!" - podName: "hello-post-network" + podName: "hello-post-network" \ No newline at end of file diff --git a/resources/plugins/circuitbreaker/README.md b/resources/plugins/circuitbreaker/README.md new file mode 100644 index 000000000..5fb1e7f9c --- /dev/null +++ b/resources/plugins/circuitbreaker/README.md @@ -0,0 +1,151 @@ +# Circuit Breaker Plugin + +## Overview +The Circuit Breaker plugin integrates the [circuitbreaker](https://github.com/lightningequipment/circuitbreaker) tool with Warnet to protect Lightning Network nodes from being flooded with HTLCs. Circuit Breaker functions like a firewall for Lightning, allowing node operators to set limits on in-flight HTLCs and implement rate limiting on a per-peer basis. + +## What is Circuit Breaker? +Circuit Breaker is to Lightning what firewalls are to the internet. It provides protection against: +- HTLC flooding attacks +- Channel slot exhaustion (max 483 slots per channel) +- DoS/spam attacks using large numbers of fast-resolving HTLCs +- Channel balance probing attacks + +Circuit Breaker offers insights into HTLC traffic and provides configurable operating modes to handle excess traffic. + +## Usage +In your Python virtual environment with Warnet installed and set up, create a new Warnet user folder: + +``` +$ warnet new user_folder +$ cd user_folder +``` + +Deploy a network with Circuit Breaker enabled: + +``` +$ warnet deploy networks/circuitbreaker +``` + +## Configuration in `network.yaml` +You can incorporate the Circuit Breaker plugin into your `network.yaml` file as shown below: + +```yaml +nodes: + - name: tank-0000 + addnode: + - tank-0001 + ln: + lnd: true + + - name: tank-0001 + addnode: + - tank-0002 + ln: + lnd: true + + - name: tank-0002 + addnode: + - tank-0000 + ln: + lnd: true + + - name: tank-0003 + addnode: + - tank-0000 + ln: + lnd: true + lnd: + channels: + - id: + block: 300 + index: 1 + target: tank-0004-ln + capacity: 100000 + push_amt: 50000 + +plugins: + postDeploy: + circuitbreaker: + entrypoint: "../../plugins/circuitbreaker" + nodes: ["tank-0000-ln", "tank-0003-ln"] # Nodes to apply Circuit Breaker to + mode: "fail" # Operating mode: fail, queue, or queue_peer_initiated + maxPendingHtlcs: 10 # Default maximum pending HTLCs per peer + rateLimit: 1 # Minimum seconds between HTLCs (token bucket rate limit) +``` + +## Plugin Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `nodes` | List of LN node names to apply Circuit Breaker to | Required | +| `mode` | Operating mode (`fail`, `queue`, or `queue_peer_initiated`) | `fail` | +| `maxPendingHtlcs` | Default maximum number of pending HTLCs per peer | `30` | +| `rateLimit` | Minimum interval in seconds between HTLCs | `0` (disabled) | +| `port` | Port to expose the Circuit Breaker UI on | `9235` | +| `trusted_peers` | Map of node pubkeys to their individual HTLC limits | `{}` | + +## Operating Modes + +- **fail**: Fail HTLCs when limits are exceeded. Minimizes liquidity lock-up but affects routing reputation. +- **queue**: Queue HTLCs when limits are exceeded, forwarding them when space becomes available. Penalizes upstream nodes for bad traffic. +- **queue_peer_initiated**: Queue only HTLCs from channels that the remote node initiated. Uses fail mode for channels we initiated. + +**WARNING**: Queue modes require LND 0.16+ with auto-fail support to prevent force-closes. + +## Accessing the UI + +After deploying, you can port-forward to access the Circuit Breaker UI: + +``` +$ kubectl port-forward pod/circuitbreaker-tank-0000 9235:9235 +``` + +Then open http://127.0.0.1:9235 in a browser to view and configure Circuit Breaker settings. + +## Advanced Configuration Example + +```yaml +plugins: + postDeploy: + circuitbreaker: + entrypoint: "../../plugins/circuitbreaker" + nodes: ["tank-0000-ln", "tank-0003-ln"] + mode: "fail" + maxPendingHtlcs: 15 + rateLimit: 0.5 + trusted_peers: { + "03abcdef...": 50, + "02123456...": 100 + } +``` + + + +## Limitations + +- Circuit Breaker is alpha quality software. Use with caution, especially on mainnet. +- LND interfaces are not optimized for this purpose, which may lead to edge cases. +- Queue modes require LND 0.16+ to prevent channel force-closes. + +## Development + +To build your own version of the Circuit Breaker plugin: + +1. Clone the Circuit Breaker repository: `git clone https://github.com/lightningequipment/circuitbreaker.git` +2. Follow the build instructions in the repository +3. Update the plugin's `values.yaml` to point to your custom image \ No newline at end of file diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/.helmignore b/resources/plugins/circuitbreaker/charts/circuitbreaker/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/Chart.yaml b/resources/plugins/circuitbreaker/charts/circuitbreaker/Chart.yaml new file mode 100644 index 000000000..6cb99f1b8 --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: circuitbreaker +description: A Helm chart to deploy Circuit Breaker +version: 0.1.0 +appVersion: "0.1.0" \ No newline at end of file diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/_helpers.tpl b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/_helpers.tpl new file mode 100644 index 000000000..a699083e5 --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/_helpers.tpl @@ -0,0 +1,7 @@ +{{- define "mychart.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "mychart.fullname" -}} +{{- printf "%s-%s" (include "mychart.name" .) .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/configmap.yaml b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/configmap.yaml new file mode 100644 index 000000000..3216c79ea --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/configmap.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "mychart.fullname" . }}-data +data: + tls.cert: | + -----BEGIN CERTIFICATE----- + MIICRTCCAeygAwIBAgIRAMe5IfFsBM9nqG1hwA1tswAwCgYIKoZIzj0EAwIwOzEf + MB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEYMBYGA1UEAxMPREVTS1RP + UC00OEJVR0xTMB4XDTI1MDIwMTE2NDAyNFoXDTI2MDMyOTE2NDAyNFowOzEfMB0G + A1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEYMBYGA1UEAxMPREVTS1RPUC00 + OEJVR0xTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIl4bWvtGVb1T4iUyjLfj + U2IVnF1yJBwbTa2diRJh+a0UbwjUSdn/hIVkNALr9f3NKYWmotyq8IGOmjwhAFis + HKOB0DCBzTAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYD + VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU38wWLmz1lsVv7vtZZamSgkcoQUcwdgYD + VR0RBG8wbYIPREVTS1RPUC00OEJVR0xTgglsb2NhbGhvc3SCBHVuaXiCCnVuaXhw + YWNrZXSCB2J1ZmNvbm6HBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAGHBAr///6HBKwd + IqaHEP6AAAAAAAAAAhVd//6GM+MwCgYIKoZIzj0EAwIDRwAwRAIgNe9zoH9iz7Tw + 1j8+Jk05DU6nJ48a5mbP0viZ50UGu7sCIEK0AoPBrqxnicdhEEInONWyIm5VUR/l + YURZZyNuJ8lJ + -----END CERTIFICATE----- + admin.macaroon.hex: | + 0201036C6E6402F801030A107EC4D3E96DE93FA58F70968A1729AE6C1201301A160A0761646472657373120472656164120577726974651A130A04696E666F120472656164120577726974651A170A08696E766F69636573120472656164120577726974651A210A086D616361726F6F6E120867656E6572617465120472656164120577726974651A160A076D657373616765120472656164120577726974651A170A086F6666636861696E120472656164120577726974651A160A076F6E636861696E120472656164120577726974651A140A057065657273120472656164120577726974651A180A067369676E6572120867656E65726174651204726561640000062023AFC3BF7DB1D186342905D79461793FFCB59F583858F495C253F0A1EB4D33C2 diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/pod.yaml b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/pod.yaml new file mode 100644 index 000000000..459371b33 --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/pod.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "mychart.fullname" . }} + labels: + app: {{ include "mychart.name" . }} + mission: {{ .Values.name }} +spec: + initContainers: + - name: "init" + image: "busybox" + command: + - "sh" + - "-c" + args: + - > + mkdir -p /shared/.lnd/data/chain/bitcoin/mainnet && + cp /configmap/tls.cert /shared/.lnd/tls.cert && + cat /configmap/admin.macaroon.hex | xxd -r -p > /shared/.lnd/data/chain/bitcoin/mainnet/admin.macaroon + volumeMounts: + - name: shared-volume + mountPath: /shared + - name: configmap-volume + mountPath: /configmap + containers: + - name: {{ .Values.name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - "sh" + - "-c" + args: + - > + mkdir -p /root/.lnd/data/chain/bitcoin/mainnet && + ln -s /shared/.lnd/tls.cert /root/.lnd/tls.cert && + ln -s /shared/.lnd/data/chain/bitcoin/mainnet/admin.macaroon /root/.lnd/data/chain/bitcoin/mainnet/admin.macaroon && + circuitbreaker --rpcserver={{ .Values.lnd.rpcserver }} --httplisten={{ .Values.lnd.httplisten }} + volumeMounts: + - name: shared-volume + mountPath: /shared + volumes: + - name: configmap-volume + configMap: + name: {{ include "mychart.fullname" . }}-data + - name: shared-volume + emptyDir: {} \ No newline at end of file diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/service.yaml b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/service.yaml new file mode 100644 index 000000000..42d17b602 --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "mychart.fullname" . }}-service + labels: + app: {{ include "mychart.name" . }} +spec: + type: NodePort + ports: + - port: 9235 + targetPort: 9235 + nodePort: 30000 # Choose a port between 30000-32767 + selector: + app: {{ include "mychart.name" . }} \ No newline at end of file diff --git a/resources/plugins/circuitbreaker/charts/circuitbreaker/values.yaml b/resources/plugins/circuitbreaker/charts/circuitbreaker/values.yaml new file mode 100644 index 000000000..e052a8f91 --- /dev/null +++ b/resources/plugins/circuitbreaker/charts/circuitbreaker/values.yaml @@ -0,0 +1,14 @@ +name: "circuitbreaker" +image: + repository: "camillarhi/circuitbreaker" + tag: "latest" + pullPolicy: IfNotPresent +workingVolume: + name: working-volume + mountPath: /working +configmapVolume: + name: configmap-volume + mountPath: /configmap +lnd: + rpcserver: "172.29.34.166:10009" # Default LND RPC server address + httplisten: "0.0.0.0:9235" # Default HTTP listen address \ No newline at end of file diff --git a/resources/plugins/circuitbreaker/plugin.py b/resources/plugins/circuitbreaker/plugin.py new file mode 100644 index 000000000..6700c0b88 --- /dev/null +++ b/resources/plugins/circuitbreaker/plugin.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +import json +import logging +from enum import Enum +from pathlib import Path +import subprocess +import time +from typing import Optional + +import click + +from warnet.constants import PLUGIN_ANNEX, AnnexMember, HookValue, WarnetContent +from warnet.process import run_command + +from warnet.k8s import ( + download, + get_default_namespace, + get_mission, + get_static_client, + wait_for_init, + write_file_to_container, +) + +MISSION = "circuitbreaker" +PRIMARY_CONTAINER = MISSION + +PLUGIN_DIR_TAG = "plugin_dir" + +class PluginError(Exception): + pass + +log = logging.getLogger(MISSION) +if not log.hasHandlers(): + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + console_handler.setFormatter(formatter) + log.addHandler(console_handler) +log.setLevel(logging.DEBUG) +log.propagate = True + +class PluginContent(Enum): + POD_NAME = "podName" + LND_RPC_SERVER = "rpcserver" + HTTP_LISTEN = "httplisten" + +@click.group() +@click.pass_context +def circuitbreaker(ctx): + """Commands for the Circuit Breaker plugin""" + ctx.ensure_object(dict) + plugin_dir = Path(__file__).resolve().parent + ctx.obj[PLUGIN_DIR_TAG] = Path(plugin_dir) + + +@circuitbreaker.command() +@click.argument("plugin_content", type=str) +@click.argument("warnet_content", type=str) +@click.pass_context +def entrypoint(ctx, plugin_content: str, warnet_content: str): + """Plugin entrypoint""" + plugin_content: dict = json.loads(plugin_content) + warnet_content: dict = json.loads(warnet_content) + + hook_value = warnet_content.get(WarnetContent.HOOK_VALUE.value) + + assert hook_value in { + item.value for item in HookValue + }, f"{hook_value} is not a valid HookValue" + + if warnet_content.get(PLUGIN_ANNEX): + for annex_member in [annex_item for annex_item in warnet_content.get(PLUGIN_ANNEX)]: + assert annex_member in { + item.value for item in AnnexMember + }, f"{annex_member} is not a valid AnnexMember" + + warnet_content[WarnetContent.HOOK_VALUE.value] = HookValue(hook_value) + + _entrypoint(ctx, plugin_content, warnet_content) + + +def _entrypoint(ctx, plugin_content: dict, warnet_content: dict): + """Called by entrypoint""" + hook_value = warnet_content[WarnetContent.HOOK_VALUE.value] + + match hook_value: + case HookValue.POST_DEPLOY: + data = get_data(plugin_content) + if data: + _launch_pod(ctx, install_name="circuitbreaker", **data) + else: + _launch_pod(ctx, install_name="circuitbreaker") + case _: + log.info(f"No action required for hook {hook_value}") + +def get_data(plugin_content: dict) -> Optional[dict]: + data = { + key: plugin_content.get(key) + for key in (PluginContent.POD_NAME.value, PluginContent.LND_RPC_SERVER.value, PluginContent.HTTP_LISTEN.value) + if plugin_content.get(key) + } + return data or None + +# def _create_secrets(): +# """Use local LND files for testing""" +# log.info("Using local LND files for testing") +# tls_cert_path = Path.home() / ".lnd" / "tls.cert" +# admin_macaroon_path = Path.home() / ".lnd" / "data" / "chain" / "bitcoin" / "signet" / "admin.macaroon" + +# if not tls_cert_path.exists(): +# raise PluginError(f"TLS certificate not found at {tls_cert_path}") +# if not admin_macaroon_path.exists(): +# raise PluginError(f"Admin macaroon not found at {admin_macaroon_path}") + +# log.info(f"Using TLS certificate: {tls_cert_path}") +# log.info(f"Using admin macaroon: {admin_macaroon_path}") + +# def _create_secrets(): +# """Create Kubernetes secrets for each LND node""" +# lnd_pods = subprocess.check_output(["kubectl", "get", "pods", "-l", "mission=lightning", "-o", "name"]).decode().splitlines() +# # lnd_pods = subprocess.check_output(["kubectl", "get", "pods", "-l", "app=warnet", "-l", "mission=lightning", "-o", "name"]).decode().splitlines() +# for node in lnd_pods: +# node_name = node.split('/')[-1] +# log.info(f"Waiting for {node_name} to be ready...") +# wait_for_init(node_name, namespace=get_default_namespace(), quiet=True) +# log.info(f"Creating secrets for {node_name}") +# subprocess.run(["kubectl", "cp", f"{node}:/root/.lnd/tls.cert", "./tls.cert"], check=True) +# subprocess.run(["kubectl", "cp", f"{node}:/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon", "./admin.macaroon"], check=True) +# subprocess.run(["kubectl", "create", "secret", "generic", f"lnd-tls-cert-{node_name}", "--from-file=tls.cert=./tls.cert"], check=True) +# subprocess.run(["kubectl", "create", "secret", "generic", f"lnd-macaroon-{node_name}", "--from-file=admin.macaroon=./admin.macaroon"], check=True) + +def _create_secrets(): + """Create Kubernetes secrets for each LND node""" + lnd_pods = subprocess.check_output( + ["kubectl", "get", "pods", "-l", "mission=lightning", "-o", "name"] + ).decode().splitlines() + + for node in lnd_pods: + node_name = node.split('/')[-1] + log.info(f"Waiting for {node_name} to be ready...") + + # Wait for the pod to be ready + max_retries = 10 + retry_delay = 10 # seconds + for attempt in range(max_retries): + try: + # Check if the pod is ready + pod_status = subprocess.check_output( + ["kubectl", "get", "pod", node_name, "-o", "jsonpath='{.status.phase}'"] + ).decode().strip("'") + + if pod_status == "Running": + log.info(f"{node_name} is ready.") + break + else: + log.info(f"{node_name} is not ready yet (status: {pod_status}). Retrying in {retry_delay} seconds...") + except subprocess.CalledProcessError as e: + log.error(f"Failed to check pod status for {node_name}: {e}") + if attempt == max_retries - 1: + raise PluginError(f"Pod {node_name} did not become ready after {max_retries} attempts.") + + time.sleep(retry_delay) + + # Create secrets for the pod + log.info(f"Creating secrets for {node_name}") + try: + subprocess.run( + ["kubectl", "cp", f"{node_name}:/root/.lnd/tls.cert", "./tls.cert"], + check=True + ) + subprocess.run( + ["kubectl", "cp", f"{node_name}:/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon", "./admin.macaroon"], + check=True + ) + subprocess.run( + ["kubectl", "create", "secret", "generic", f"lnd-tls-cert-{node_name}", "--from-file=tls.cert=./tls.cert"], + check=True + ) + subprocess.run( + ["kubectl", "create", "secret", "generic", f"lnd-macaroon-{node_name}", "--from-file=admin.macaroon=./admin.macaroon"], + check=True + ) + except subprocess.CalledProcessError as e: + log.error(f"Failed to create secrets for {node_name}: {e}") + raise PluginError(f"Failed to create secrets for {node_name}.") + +def _launch_pod(ctx, + install_name: str = "circuitbreaker", + podName: str = "circuitbreaker-pod", + rpcserver: str = "localhost:10009", + httplisten: str = "0.0.0.0:9235"): + timestamp = int(time.time()) + # release_name = f"cb-{install_name}" + + command = ( + f"helm upgrade --install {install_name} {ctx.obj[PLUGIN_DIR_TAG]}/charts/circuitbreaker " + f"--set podName={podName} --set rpcserver={rpcserver} --set httplisten={httplisten}" + ) + + log.info(command) + log.info(run_command(command)) + +if __name__ == "__main__": + circuitbreaker()