Skip to content

Commit e9ffa29

Browse files
committed
Add circuit breaker plugin
1 parent 072aeca commit e9ffa29

File tree

6 files changed

+296
-2
lines changed

6 files changed

+296
-2
lines changed

docs/circuit-breaker.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Circuit Breaker for Warnet
2+
3+
## Overview
4+
5+
Circuit Breaker is a Lightning Network firewall that protects LND nodes from being flooded with HTLCs. When integrated with Warnet, Circuit Breaker runs as a sidecar container alongside your LND nodes.
6+
7+
Circuit Breaker is to Lightning what firewalls are to the internet - it allows nodes to protect themselves by setting maximum limits on in-flight HTLCs on a per-peer basis and applying rate limits to forwarded HTLCs.
8+
9+
* **Repository**: https://github.com/lightningequipment/circuitbreaker
10+
* **Full Documentation**: See the main repository for detailed information about Circuit Breaker's features, operating modes, and configuration options
11+
12+
## Usage in Warnet
13+
14+
### Basic Configuration
15+
16+
To enable Circuit Breaker for an LND node in your `network.yaml` file, add the `circuitbreaker` section under the `lnd` configuration:
17+
18+
```yaml
19+
nodes:
20+
- name: tank-0003
21+
addnode:
22+
- tank-0000
23+
ln:
24+
lnd: true
25+
lnd:
26+
config: |
27+
bitcoin.timelockdelta=33
28+
channels:
29+
- id:
30+
block: 300
31+
index: 1
32+
target: tank-0004-ln
33+
capacity: 100000
34+
push_amt: 50000
35+
circuitbreaker:
36+
enabled: true # This enables Circuit Breaker for this node
37+
httpPort: 9235 # Can override default port per-node (optional)
38+
```
39+
40+
### Configuration Options
41+
42+
- `enabled`: Set to `true` to enable Circuit Breaker for the node
43+
- `httpPort`: Override the default HTTP port (9235) for the web UI (optional)
44+
45+
### Complete Example
46+
47+
Here's a complete `network.yaml` example with Circuit Breaker enabled on one node:
48+
49+
```yaml
50+
nodes:
51+
- name: tank-0000
52+
addnode:
53+
- tank-0001
54+
ln:
55+
lnd: true
56+
57+
- name: tank-0001
58+
addnode:
59+
- tank-0002
60+
ln:
61+
lnd: true
62+
63+
- name: tank-0002
64+
addnode:
65+
- tank-0000
66+
ln:
67+
lnd: true
68+
69+
- name: tank-0003
70+
addnode:
71+
- tank-0000
72+
ln:
73+
lnd: true
74+
lnd:
75+
config: |
76+
bitcoin.timelockdelta=33
77+
channels:
78+
- id:
79+
block: 300
80+
index: 1
81+
target: tank-0004-ln
82+
capacity: 100000
83+
push_amt: 50000
84+
circuitbreaker:
85+
enabled: true
86+
httpPort: 9235
87+
88+
- name: tank-0004
89+
addnode:
90+
- tank-0000
91+
ln:
92+
lnd: true
93+
lnd:
94+
channels:
95+
- id:
96+
block: 300
97+
index: 2
98+
target: tank-0005-ln
99+
capacity: 50000
100+
push_amt: 25000
101+
102+
- name: tank-0005
103+
addnode:
104+
- tank-0000
105+
ln:
106+
lnd: true
107+
```
108+
109+
## Accessing the Web UI
110+
111+
Circuit Breaker provides a web-based interface for configuration and monitoring. To access it:
112+
113+
1. **Port Forward to the Circuit Breaker service**:
114+
```bash
115+
kubectl port-forward pod/<node-name>-ln <local-port>:<httpPort>
116+
```
117+
118+
For example, if your node is named `tank-0003` and using the default port:
119+
```bash
120+
kubectl port-forward pod/tank-0003-ln 9235:9235
121+
```
122+
123+
2. **Open your browser** and navigate to:
124+
```
125+
http://localhost:9235
126+
```
127+
128+
3. **Configure your firewall rules** through the web interface:
129+
- Set per-peer HTLC limits
130+
- Configure rate limiting parameters
131+
- Choose operating modes
132+
- Monitor HTLC statistics
133+
134+
## Architecture
135+
136+
Circuit Breaker runs as a sidecar container alongside your LND node in Warnet:
137+
- **LND Container**: Runs your Lightning node
138+
- **Circuit Breaker Container**: Connects to LND via RPC and provides firewall functionality
139+
- **Shared Volume**: Allows Circuit Breaker to access LND's TLS certificates and macaroons
140+
- **Web Interface**: Accessible via port forwarding for configuration
141+
142+
## Requirements
143+
144+
- **LND Version**: 0.15.4-beta or above
145+
- **Warnet**: Compatible with standard Warnet LND deployments
146+
147+
## Support
148+
149+
For issues and questions:
150+
- Circuit Breaker Repository: https://github.com/lightningequipment/circuitbreaker
151+
- Warnet Documentation: Refer to the Warnet installation guides [install.md](install.md)
152+
- LND Documentation: https://docs.lightning.engineering/
153+
154+
---
155+
156+
*Circuit Breaker integration for Warnet enables sophisticated HTLC management and protection for Lightning Network nodes in test environments.*

resources/charts/bitcoincore/charts/lnd/templates/pod.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,37 @@ spec:
5858
- mountPath: /root/.lnd/tls.cert
5959
name: config
6060
subPath: tls.cert
61+
- name: shared-volume
62+
mountPath: /root/.lnd/
6163
{{- with .Values.extraContainers }}
6264
{{- toYaml . | nindent 4 }}
6365
{{- end }}
66+
{{- if .Values.circuitbreaker.enabled }}
67+
- name: circuitbreaker
68+
image: {{ .Values.circuitbreaker.image | quote }}
69+
imagePullPolicy: IfNotPresent
70+
args:
71+
- "--network={{ .Values.global.chain }}"
72+
- "--rpcserver=localhost:{{ .Values.RPCPort }}"
73+
- "--tlscertpath=/tls.cert"
74+
- "--macaroonpath=/root/.lnd/data/chain/bitcoin/{{ .Values.global.chain }}/admin.macaroon"
75+
- "--httplisten=0.0.0.0:{{ .Values.circuitbreaker.httpPort }}"
76+
volumeMounts:
77+
- name: shared-volume
78+
mountPath: /root/.lnd/
79+
- name: config
80+
mountPath: /tls.cert
81+
subPath: tls.cert
82+
{{- end }}
6483
volumes:
6584
{{- with .Values.volumes }}
6685
{{- toYaml . | nindent 4 }}
6786
{{- end }}
6887
- configMap:
6988
name: {{ include "lnd.fullname" . }}
7089
name: config
90+
- name: shared-volume
91+
emptyDir: {}
7192
{{- with .Values.nodeSelector }}
7293
nodeSelector:
7394
{{- toYaml . | nindent 4 }}

resources/charts/bitcoincore/charts/lnd/values.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,8 @@ config: ""
132132
defaultConfig: ""
133133

134134
channels: []
135+
136+
circuitbreaker:
137+
enabled: false # Default to disabled
138+
image: carlakirkcohen/circuitbreaker:attackathon-test
139+
httpPort: 9235

test/data/ln/network.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ nodes:
2121
target: tank-0004-ln
2222
capacity: 100000
2323
push_amt: 50000
24+
circuitbreaker:
25+
enabled: true
26+
httpPort: 9235
27+
2428
- name: tank-0004
2529
addnode:
2630
- tank-0000

test/data/network_with_plugins/network.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ nodes:
3232
target: tank-0004-ln
3333
capacity: 100000
3434
push_amt: 50000
35+
circuitbreaker:
36+
enabled: true # This enables circuitbreaker for this node
37+
httpPort: 9235 # Can override defaults per-node
3538

3639
- name: tank-0004
3740
addnode:
@@ -85,3 +88,4 @@ plugins: # Each plugin section has a number of hooks available (preDeploy, post
8588
entrypoint: "../plugins/hello"
8689
helloTo: "postNetwork!"
8790
podName: "hello-post-network"
91+

test/ln_basic_test.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
import json
44
import os
5+
import random
6+
import subprocess
7+
import time
58
from pathlib import Path
69
from time import sleep
710

11+
import requests
812
from test_base import TestBase
913

10-
from warnet.process import stream_command
14+
from warnet.process import run_command
1115

1216

1317
class LNBasicTest(TestBase):
@@ -24,11 +28,18 @@ def __init__(self):
2428
"tank-0005-ln",
2529
]
2630

31+
self.cb_port = 9235
32+
self.cb_node = "tank-0003-ln"
33+
self.port_forward = None
34+
2735
def run_test(self):
2836
try:
2937
# Wait for all nodes to wake up. ln_init will start automatically
3038
self.setup_network()
3139

40+
# Test circuit breaker API
41+
self.test_circuit_breaker_api()
42+
3243
# Send a payment across channels opened automatically by ln_init
3344
self.pay_invoice(sender="tank-0005-ln", recipient="tank-0003-ln")
3445

@@ -39,11 +50,12 @@ def run_test(self):
3950
self.pay_invoice(sender="tank-0000-ln", recipient="tank-0002-ln")
4051

4152
finally:
53+
self.cleanup_kubectl_created_services()
4254
self.cleanup()
4355

4456
def setup_network(self):
4557
self.log.info("Setting up network")
46-
stream_command(f"warnet deploy {self.network_dir}")
58+
run_command(f"warnet deploy {self.network_dir}")
4759

4860
def fund_wallets(self):
4961
outputs = ""
@@ -120,6 +132,98 @@ def scenario_open_channels(self):
120132
self.log.info(f"Running scenario from: {scenario_file}")
121133
self.warnet(f"run {scenario_file} --source_dir={self.scen_dir} --debug")
122134

135+
def test_circuit_breaker_api(self):
136+
self.log.info("Testing Circuit Breaker API")
137+
138+
# Set up port forwarding to the circuit breaker
139+
cb_url = self.setup_api_access(self.cb_node)
140+
141+
self.log.info(f"Testing Circuit Breaker API at {cb_url}")
142+
143+
# Test /info endpoint
144+
info = self.cb_api_request(cb_url, "get", "/info")
145+
assert "version" in info, "Circuit breaker info missing version"
146+
147+
# Test /limits endpoint
148+
limits = self.cb_api_request(cb_url, "get", "/limits")
149+
assert isinstance(limits, dict), "Limits should be a dictionary"
150+
151+
self.log.info("✅ Circuit Breaker API tests passed")
152+
153+
def setup_api_access(self, pod_name):
154+
"""Set up Kubernetes Service access to the Circuit Breaker API"""
155+
# Create a properly labeled service using kubectl expose
156+
service_name = f"{pod_name}-svc"
157+
158+
self.log.info(f"Creating service {service_name} for pod {pod_name}")
159+
160+
command = f"kubectl expose pod {pod_name} --name {service_name} --port {self.cb_port} --target-port {self.cb_port}"
161+
result = run_command(command)
162+
self.log.info(f"Service creation command output: {result}")
163+
164+
time.sleep(51) # Wait for the service to be created
165+
166+
service_url = f"http://{service_name}:{self.cb_port}/api"
167+
self.service_to_cleanup = service_name
168+
self.log.info(f"Service URL: {service_url}")
169+
170+
self.log.info(f"Successfully created service at {service_url}")
171+
return service_url
172+
173+
def cb_api_request(self, base_url, method, endpoint, data=None):
174+
"""Universal API request handler with proper path handling"""
175+
try:
176+
# Parse the base URL components
177+
url_parts = base_url.split("://")[1].split("/")
178+
service_name = url_parts[0].split(":")[0]
179+
port = url_parts[0].split(":")[1] if ":" in url_parts[0] else "80"
180+
base_path = "/" + "/".join(url_parts[1:]) if len(url_parts) > 1 else "/"
181+
182+
# Set up port forwarding
183+
local_port = random.randint(10000, 20000)
184+
pf = subprocess.Popen(
185+
["kubectl", "port-forward", f"svc/{service_name}", f"{local_port}:{port}"],
186+
stdout=subprocess.PIPE,
187+
stderr=subprocess.PIPE,
188+
)
189+
190+
try:
191+
# Wait for port-forward to establish
192+
time.sleep(2)
193+
194+
# Construct the full local URL with proper path handling
195+
full_path = base_path.rstrip("/") + "/" + endpoint.lstrip("/")
196+
local_url = f"http://localhost:{local_port}{full_path}"
197+
198+
self.log.debug(f"Attempting API request to: {local_url}")
199+
200+
# Make the request
201+
if method.lower() == "get":
202+
response = requests.get(local_url, timeout=30)
203+
else:
204+
response = requests.post(local_url, json=data, timeout=30)
205+
206+
response.raise_for_status()
207+
return response.json()
208+
209+
finally:
210+
pf.terminate()
211+
pf.wait()
212+
213+
except Exception as e:
214+
self.log.error(f"API request to {local_url} failed: {str(e)}")
215+
raise
216+
217+
def cleanup_kubectl_created_services(self):
218+
"""Clean up any created resources"""
219+
if hasattr(self, "service_to_cleanup") and self.service_to_cleanup:
220+
self.log.info(f"Deleting service {self.service_to_cleanup}")
221+
subprocess.run(
222+
["kubectl", "delete", "svc", self.service_to_cleanup],
223+
stdout=subprocess.DEVNULL,
224+
stderr=subprocess.DEVNULL,
225+
)
226+
123227

124228
if __name__ == "__main__":
125229
test = LNBasicTest()

0 commit comments

Comments
 (0)