Skip to content

Comments

feat: add Tor hidden service and outbound .onion peer connections#2038

Closed
sat-engineer wants to merge 2 commits intogetAlby:masterfrom
sat-engineer:feat/tor-socks-outbound
Closed

feat: add Tor hidden service and outbound .onion peer connections#2038
sat-engineer wants to merge 2 commits intogetAlby:masterfrom
sat-engineer:feat/tor-socks-outbound

Conversation

@sat-engineer
Copy link

@sat-engineer sat-engineer commented Feb 7, 2026

Summary

  • New tor/ package: Go-native Tor hidden service management via control port (cretz/bine library), with key persistence, retry logic, and lifecycle management
  • Wire up builder.SetTorProxyAddress() in LDK service init when LDK_TOR_SOCKS_HOST is set, with DNS resolution for Docker hostnames
  • Expose onion address in node connection info API
  • New env vars: LDK_TOR_ENABLED, LDK_TOR_CONTROL_HOST/PORT/PASSWORD, LDK_TOR_TARGET_HOST, LDK_TOR_SOCKS_HOST/PORT
  • Frontend: Tor settings page, onion URI copy button on Channels page, Settings sidebar entry (LDK backend only)

Closes #2035

Related PRs

This is part of a 3-PR series enabling Tor .onion-to-.onion Lightning peer connections in Alby Hub:

  1. Rust SOCKS5 implementationfeat: add Tor SOCKS5 proxy support for outbound .onion connections ldk-node#91
  2. Go/C bindings rebuildfeat: add SetTorProxyAddress binding for .onion connections ldk-node-go#34
  3. Alby Hub integration (tor package, env vars, frontend) 👈 you are here — feat: add Tor hidden service and outbound .onion peer connections #2038

Dependency chain: PR 1 → PR 2 → PR 3

Test plan

  • go test ./tor/ -race — 21 tests passing (key management, service lifecycle, concurrency, encoding)
  • go build ./... passes
  • Deployed on two Umbrel instances with Tor hidden services
  • Peers connected over .onion, opened channel, sent Lightning payments (1k sats, 2k sats)
  • Verified Tor service starts/stops cleanly with container lifecycle

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com

Summary by CodeRabbit

New Features

  • Added Tor hidden service support for Lightning Network nodes on LDK backend, enabling private connectivity
  • New Tor settings page with configuration management and hidden service status display
  • Display of onion address and Tor-based node URI for secure peer connections
  • Environment variable support for Tor control and SOCKS proxy configuration

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

Adds optional Tor hidden-service support: new tor package, LDK integration (SOCKS proxy and onion service lifecycle), new config flags, API and frontend exposure of Tor status and onion address, plus UI for Tor settings and copyable Tor node URI.

Changes

Cohort / File(s) Summary
Config & API models
config/models.go, api/models.go, lnclient/models.go, api/models.go
Added AppConfig Tor flags/hosts/ports/passwords; added TorAddress/TorPort to NodeConnectionInfo and TorEnabled to InfoResponse. Review JSON tags and env defaults.
LDK integration
lnclient/ldk/ldk.go
Added torService field; configures Tor SOCKS proxy if set; starts/stops Tor hidden service after sync; exposes onion address/port in node info. Pay attention to startup/shutdown ordering and error logging.
Tor service package
tor/tor.go, tor/tor_test.go
New tor package implementing Service (Start/Stop/GetOnionAddress/IsRunning), key persistence, control port auth, ADD_ONION lifecycle, and extensive tests. Focus review on concurrency, retry/backoff, key file handling, and cleanup semantics.
Frontend types & UI
frontend/src/types.ts, frontend/src/screens/settings/TorSettings.tsx, frontend/src/screens/channels/Channels.tsx, frontend/src/routes.tsx, frontend/src/components/layouts/SettingsLayout.tsx
Added torEnabled/torAddress/torPort types; new TorSettings screen, settings route/menu entry (shown for LDK backend), Tor URI in Channels dropdown, copy-to-clipboard UI. Check conditional rendering and clipboard formatting.
API surface
api/api.go
GetInfo now sets TorEnabled based on LDK and config flag — verify exposure semantics.
Dependencies & go.mod
go.mod
Added github.com/cretz/bine and updated ldk-node-go replace for Tor SOCKS5 support. Confirm dependency intents and compatibility.
Misc / tests
tor/tor_test.go
Comprehensive unit tests for tor package: key management, lifecycle, concurrency, and defaults. Review for flakiness and environment assumptions.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Config as Config (LDK_TOR_*)
    participant LDK as LDK Service
    participant TorSvc as Tor Service
    participant CtrlPort as Tor Control Port
    participant Frontend as Frontend UI

    App->>Config: load Tor env vars
    App->>LDK: init with config
    LDK->>LDK: setup Tor SOCKS proxy (if configured)
    LDK->>TorSvc: Start(cfg) after initial sync
    TorSvc->>CtrlPort: connect & authenticate
    CtrlPort-->>TorSvc: auth ok
    TorSvc->>TorSvc: load/create onion key
    TorSvc->>CtrlPort: ADD_ONION (map onionPort -> target)
    CtrlPort-->>TorSvc: return ServiceID (onion address)
    TorSvc-->>LDK: onion address available
    LDK-->>App: GetNodeConnectionInfo includes TorAddress/TorPort
    App->>Frontend: API returns info with torEnabled and TorAddress
    Frontend->>Frontend: display TorSettings / copy Tor URI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇
I nibble keys in moonlit code, a tiny hop and spin,
I plant an onion blossom where hidden routes begin,
SOCKS whispers through the tunnels, peers may now arrive,
I copy URIs with joy — our lightning keeps alive!

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: adding Tor hidden service support and outbound .onion peer connection capability.
Linked Issues check ✅ Passed The pull request implements all core technical objectives from issue #2035: Tor control integration via cretz/bine, onion key persistence, node URI exposure, settings UI, and Linux/Docker support.
Out of Scope Changes check ✅ Passed All changes are directly related to Tor hidden service and .onion peer connectivity. No extraneous modifications detected outside the stated PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@frontend/src/screens/settings/TorSettings.tsx`:
- Around line 19-24: hasTor currently only checks torAddress which allows
torPort to be undefined and produce a URI like pubkey@address:undefined; update
the guard so hasTor requires both torAddress and torPort (e.g., check truthiness
of nodeConnectionInfo?.torAddress and nodeConnectionInfo?.torPort) and keep
torUri building logic (using nodeConnectionInfo?.pubkey, torAddress and torPort)
unchanged so the component only renders a URI when both values exist.

In `@go.mod`:
- Around line 273-274: go.mod currently replaces github.com/getAlby/ldk-node-go
with github.com/sat-engineer/ldk-node-go v0.0.0-20260207201420-907daba13824 but
go.sum records a different pseudo-version (v0.0.0-20260207201420-ed1bf13381b8);
update the replace directive in go.mod to use the exact pseudo-version present
in go.sum (v0.0.0-20260207201420-ed1bf13381b8) or, if the intended commit is the
one ending in 907daba13824, update go.sum (via `go get module@<commit>` or
running `go mod tidy`) so both files reference the same commit for
github.com/sat-engineer/ldk-node-go; ensure the module name
github.com/sat-engineer/ldk-node-go and the pseudo-version commit suffix are
identical between go.mod and go.sum.

In `@lnclient/ldk/ldk.go`:
- Around line 1065-1072: GetNodeConnectionInfo reads ls.torService without
synchronization while Shutdown can set it to nil under shutdownMutex; to fix,
protect the read by taking the same lock used in Shutdown before accessing
ls.torService (e.g., change shutdownMutex to a RWMutex and use RLock/RUnlock in
GetNodeConnectionInfo or acquire shutdownMutex.Lock/Unlock briefly to copy
ls.torService into a local variable), then use the local variable to call
IsRunning/GetOnionAddress; alternatively, replace ls.torService with an
atomic.Pointer and use Load to safely read it—ensure the Shutdown assignment
uses the matching atomic Store or the same mutex so both reads and writes are
synchronized.

In `@tor/tor.go`:
- Around line 152-156: The conditional logging block checking "if resp.Key !=
nil && key == nil" is dead because loadOrCreateKey always returns a non-nil key
or errors; remove this unreachable block. If the original intent was to detect a
generated key differing from our provided key, replace the check with an
explicit comparison between resp.Key and key (using the appropriate equality
method or field comparison) and log via logger.Logger.Warn when they differ;
otherwise delete the entire resp.Key != nil && key == nil branch. Ensure
references to loadOrCreateKey, resp.Key, key, and logger.Logger.Warn are updated
accordingly.
- Around line 67-116: Start() currently holds s.mu across the long retry loop
(net.DialTimeout) which blocks Stop()/GetOnionAddress()/IsRunning(); to fix,
check and return if s.running while holding s.mu, then unlock s.mu before
starting the connection-retry loop (the block containing net.DialTimeout and
maxRetries), and once a connection is successfully created re-acquire s.mu
before setting shared state (e.g. s.running = true and storing the connection in
the Service struct like s.controlConn) and initializing any other fields; also
re-check s.running after re-acquiring the lock to avoid races where Stop()
started during the retry loop, and ensure you close the newly created conn if
you decide not to use it.
🧹 Nitpick comments (3)
tor/tor.go (1)

265-274: encodeOnionV3 does not produce a valid v3 onion address.

The function base64-encodes the first 8 bytes of the public key, whereas a v3 onion address is base32(pubkey || checksum || version) over all 32 bytes. Since the comment says "this is just for logging" and the real address comes from resp.ServiceID, this isn't functionally broken, but the function name and doc are misleading — it's really just a truncated fingerprint for log identification.

Consider renaming to something like pubkeyFingerprint to avoid confusion.

lnclient/ldk/ldk.go (1)

353-383: Magic number 9735 is repeated across three locations — use the constant.

9735 appears at lines 356, 374, and 1070. The tor package already defines DefaultOnionServicePort = 9735. Use it here to avoid drift.

Proposed fix
-		localPort := 9735
+		localPort := albytor.DefaultOnionServicePort
 		// Parse the listening port from config
 		if addrs := strings.Split(cfg.GetEnv().LDKListeningAddresses, ","); len(addrs) > 0 {
 			addr := strings.TrimSpace(addrs[0])
@@ -371,7 +371,7 @@
 			TorControlPassword: cfg.GetEnv().LDKTorControlPassword,
 			TargetHost:         cfg.GetEnv().LDKTorTargetHost,
 			LocalPort:          localPort,
-			OnionServicePort:   9735,
+			OnionServicePort:   albytor.DefaultOnionServicePort,
 			DataDir:            filepath.Join(newpath, "tor"),
 		}

And at line 1070:

-			nodeConnectionInfo.TorPort = 9735
+			nodeConnectionInfo.TorPort = albytor.DefaultOnionServicePort
api/api.go (1)

1251-1252: TorEnabled is exposed unconditionally regardless of backend type.

LDKTorEnabled is an LDK-specific config, but info.TorEnabled is set for all backend types (LND, Phoenix, Cashu). If a non-LDK user somehow sets LDK_TOR_ENABLED=true, the API would report Tor as enabled even though it has no effect. Consider gating on backendType == config.LDKBackendType.

Proposed fix
-	info.TorEnabled = api.cfg.GetEnv().LDKTorEnabled
+	info.TorEnabled = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKTorEnabled

Comment on lines 1065 to 1072
// Include Tor onion address if Tor service is running
if ls.torService != nil && ls.torService.IsRunning() {
onionAddr := ls.torService.GetOnionAddress()
if onionAddr != "" {
nodeConnectionInfo.TorAddress = onionAddr
nodeConnectionInfo.TorPort = 9735
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Data race: ls.torService is read without synchronization.

GetNodeConnectionInfo reads ls.torService (line 1066) while Shutdown writes it to nil (line 552) under shutdownMutex — but GetNodeConnectionInfo doesn't acquire that mutex. This is a data race under the Go memory model.

A simple fix: read the pointer once into a local variable under a lock, or use atomic.Pointer.

Minimal fix sketch
 	// Include Tor onion address if Tor service is running
-	if ls.torService != nil && ls.torService.IsRunning() {
-		onionAddr := ls.torService.GetOnionAddress()
+	torSvc := ls.torService // snapshot
+	if torSvc != nil && torSvc.IsRunning() {
+		onionAddr := torSvc.GetOnionAddress()
 		if onionAddr != "" {
 			nodeConnectionInfo.TorAddress = onionAddr
-			nodeConnectionInfo.TorPort = 9735
+			nodeConnectionInfo.TorPort = albytor.DefaultOnionServicePort
 		}
 	}

Note: Even this snapshot approach still has a race on the read of ls.torService. You'd need either sync.RWMutex or atomic.Pointer[albytor.Service] to fully fix it. Alternatively, protect both the assignment in Shutdown and the read here with the same mutex.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Include Tor onion address if Tor service is running
if ls.torService != nil && ls.torService.IsRunning() {
onionAddr := ls.torService.GetOnionAddress()
if onionAddr != "" {
nodeConnectionInfo.TorAddress = onionAddr
nodeConnectionInfo.TorPort = 9735
}
}
// Include Tor onion address if Tor service is running
torSvc := ls.torService // snapshot
if torSvc != nil && torSvc.IsRunning() {
onionAddr := torSvc.GetOnionAddress()
if onionAddr != "" {
nodeConnectionInfo.TorAddress = onionAddr
nodeConnectionInfo.TorPort = albytor.DefaultOnionServicePort
}
}
🤖 Prompt for AI Agents
In `@lnclient/ldk/ldk.go` around lines 1065 - 1072, GetNodeConnectionInfo reads
ls.torService without synchronization while Shutdown can set it to nil under
shutdownMutex; to fix, protect the read by taking the same lock used in Shutdown
before accessing ls.torService (e.g., change shutdownMutex to a RWMutex and use
RLock/RUnlock in GetNodeConnectionInfo or acquire shutdownMutex.Lock/Unlock
briefly to copy ls.torService into a local variable), then use the local
variable to call IsRunning/GetOnionAddress; alternatively, replace ls.torService
with an atomic.Pointer and use Load to safely read it—ensure the Shutdown
assignment uses the matching atomic Store or the same mutex so both reads and
writes are synchronized.

…ections

Add Go-native Tor hidden service management and wire up SOCKS5 proxy
support for outbound .onion peer connections via LDK-node.

Backend:
- New tor/ package: manages Tor hidden service via control port using
  cretz/bine library. Handles key persistence, retry logic, and
  lifecycle management.
- Wire up builder.SetTorProxyAddress() in LDK service init when
  LDK_TOR_SOCKS_HOST is set, with DNS resolution for Docker hostnames.
- Expose onion address in node connection info API.
- New env vars: LDK_TOR_ENABLED, LDK_TOR_CONTROL_HOST/PORT/PASSWORD,
  LDK_TOR_TARGET_HOST, LDK_TOR_SOCKS_HOST/PORT.

Frontend:
- Tor settings page showing hidden service status and onion address.
- Tor URI copy button on Channels page.
- Settings sidebar entry for Tor (LDK backend only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If two goroutines raced through Start(), the losing goroutine would
close the control connection but leave an orphaned hidden service
on the Tor daemon. Now we call DelOnion before closing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sat-engineer sat-engineer force-pushed the feat/tor-socks-outbound branch from a5c1dd7 to 770895d Compare February 7, 2026 23:36
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@lnclient/ldk/ldk.go`:
- Around line 194-209: The current Tor SOCKS proxy setup swallows
SetTorProxyAddress failures: when cfg.GetEnv().LDKTorEnabled &&
cfg.GetEnv().LDKTorSocksHost is set and builder.SetTorProxyAddress(torSocksAddr)
returns an error, change the behavior to surface the failure instead of
continuing silently — return or propagate an error from the surrounding function
(or call a controlled shutdown) so startup fails fast; locate the block using
SetTorProxyAddress, builder, LDKTorSocksHost and LDKTorSocksPort and replace the
current logger-only handling with code that returns a wrapped error (including
socks_addr and original err) to the caller (or if the function signature
disallows returning, escalate to a fatal exit), ensuring callers/users are
notified that Tor proxy setup failed.
🧹 Nitpick comments (2)
lnclient/ldk/ldk.go (1)

935-942: Duplicate alias lookup logic — consider extracting a helper.

Lines 936-939 duplicate the same pattern from lines 137-139 in NewLDKService. Both read NodeAlias config and default to "Alby Hub". If the default or key name changes, both sites need updating.

♻️ Extract a small helper
func getNodeAlias(cfg config.Config) string {
	alias, _ := cfg.Get("NodeAlias", "")
	if alias == "" {
		alias = "Alby Hub"
	}
	return alias
}

Then use getNodeAlias(ls.cfg) in both places.

tor/tor.go (1)

96-116: Retry loop: 30 retries × 2s sleep = ~60s, plus up to 5s dial timeout per attempt.

The total worst-case startup time is ~3.5 minutes if Tor never becomes available. This is acceptable for a daemon startup path, but worth noting that the caller in ldk.go treats failure as non-fatal, so this won't block the node indefinitely.

One consideration: there's no context/cancellation mechanism passed to Start(). If Shutdown() is called while Start() is retrying, the retry loop will run to completion before the service can be stopped. For an MVP this is acceptable since Stop() is no longer blocked by the mutex, but you may want to add a context.Context parameter in the future for graceful cancellation.

Comment on lines +194 to +209
// Configure Tor SOCKS proxy for outbound .onion connections
if cfg.GetEnv().LDKTorEnabled && cfg.GetEnv().LDKTorSocksHost != "" {
// Resolve hostname to IP — SetTorProxyAddress requires a numeric IP:port
socksHost := cfg.GetEnv().LDKTorSocksHost
if addrs, err := net.LookupHost(socksHost); err == nil && len(addrs) > 0 {
socksHost = addrs[0]
} else if err != nil {
logger.Logger.WithError(err).WithField("host", socksHost).Warn("Failed to resolve Tor SOCKS host, using raw hostname")
}
torSocksAddr := fmt.Sprintf("%s:%d", socksHost, cfg.GetEnv().LDKTorSocksPort)
if err := builder.SetTorProxyAddress(torSocksAddr); err != nil {
logger.Logger.WithError(err).WithField("socks_addr", torSocksAddr).Error("Failed to set Tor SOCKS proxy address")
} else {
logger.Logger.WithField("socks_addr", torSocksAddr).Info("Configured Tor SOCKS proxy for outbound .onion connections")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SOCKS proxy setup failure is silently swallowed — node will not be able to connect to .onion peers.

When SetTorProxyAddress fails (line 204), only an error is logged but startup continues normally. The user may not notice their node can't reach .onion peers. Consider either returning an error (since the user explicitly set LDK_TOR_ENABLED=true and LDK_TOR_SOCKS_HOST), or at minimum making the log level more prominent (e.g., Warn instead of Error, since Error without action is confusing).

🤖 Prompt for AI Agents
In `@lnclient/ldk/ldk.go` around lines 194 - 209, The current Tor SOCKS proxy
setup swallows SetTorProxyAddress failures: when cfg.GetEnv().LDKTorEnabled &&
cfg.GetEnv().LDKTorSocksHost is set and builder.SetTorProxyAddress(torSocksAddr)
returns an error, change the behavior to surface the failure instead of
continuing silently — return or propagate an error from the surrounding function
(or call a controlled shutdown) so startup fails fast; locate the block using
SetTorProxyAddress, builder, LDKTorSocksHost and LDKTorSocksPort and replace the
current logger-only handling with code that returns a wrapped error (including
socks_addr and original err) to the caller (or if the function signature
disallows returning, escalate to a fatal exit), ensuring callers/users are
notified that Tor proxy setup failed.

@rolznz
Copy link
Contributor

rolznz commented Feb 9, 2026

Hi, thanks for the PR. But we don't need any special logic inside Alby Hub for LND which supports TOR. Should this be inside LDK-Node instead?

@rolznz rolznz closed this Feb 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add Tor hidden service support for incoming peer connections

2 participants