From 59da00de39816fab80ba417f57ef2b5ef8b26891 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sun, 23 Nov 2025 17:31:45 +0300 Subject: [PATCH 01/12] feat(tor-support): add tor support for lnd - Implement toggleTorForNetwork action to update all LND nodes - Create applyTorFlags utility to manage Tor command flags - Update Advanced Options to display effective command with Tor flags - Tor flags added dynamically during compose generation - ui for enable tor for all nodes --- docker/lnd/Dockerfile | 4 +- docker/lnd/docker-entrypoint.sh | 35 +++++++++++++ .../common/AdvancedOptionsButton.tsx | 4 +- src/components/network/NetworkActions.tsx | 52 +++++++++++++++++-- src/i18n/locales/en-US.json | 6 +++ src/lib/docker/composeFile.ts | 16 ++++-- src/shared/types.ts | 1 + src/store/models/network.ts | 49 ++++++++++++++++- src/utils/network.ts | 52 +++++++++++++++++++ 9 files changed, 207 insertions(+), 12 deletions(-) diff --git a/docker/lnd/Dockerfile b/docker/lnd/Dockerfile index c28aeda94b..47b69b267c 100644 --- a/docker/lnd/Dockerfile +++ b/docker/lnd/Dockerfile @@ -4,7 +4,7 @@ ARG LND_VERSION ENV PATH=/opt/lnd:$PATH RUN apt-get update -y \ - && apt-get install -y curl gosu wait-for-it \ + && apt-get install -y curl gosu wait-for-it tor \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -28,7 +28,7 @@ RUN chmod a+x /entrypoint.sh VOLUME ["/home/lnd/.lnd"] -EXPOSE 9735 8080 10000 +EXPOSE 9735 8080 10000 9050 9051 ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/lnd/docker-entrypoint.sh b/docker/lnd/docker-entrypoint.sh index 365781577a..41b7722932 100644 --- a/docker/lnd/docker-entrypoint.sh +++ b/docker/lnd/docker-entrypoint.sh @@ -14,6 +14,41 @@ if ! id lnd > /dev/null 2>&1; then chown -R $USERID:$GROUPID /home/lnd fi + +if [ "${ENABLE_TOR}" = "true" ]; then + if getent group debian-tor > /dev/null 2>&1; then + usermod -a -G debian-tor lnd + fi + + echo "Starting Tor service..." + mkdir -p /var/lib/tor/lnd-service + chown -R debian-tor:debian-tor /var/lib/tor + chmod 700 /var/lib/tor + chmod 700 /var/lib/tor/lnd-service + + # Generate torrc + cat > /etc/tor/torrc < = ({ node, type }) => { const handleClick = () => { showAdvancedOptions({ nodeName: node.name, - command: node.docker.command, + command: getEffectiveCommand(node), defaultCommand: getDefaultCommand(node.implementation, node.version), }); }; diff --git a/src/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index 12410f7b63..367cc0528b 100644 --- a/src/components/network/NetworkActions.tsx +++ b/src/components/network/NetworkActions.tsx @@ -3,21 +3,23 @@ import { CloseOutlined, ExportOutlined, FormOutlined, + LockOutlined, MoreOutlined, ToolOutlined, + UnlockOutlined, } from '@ant-design/icons'; import styled from '@emotion/styled'; -import { Button, Divider, Dropdown, MenuProps, Tag } from 'antd'; +import { Button, Divider, Dropdown, MenuProps, Switch, Tag, Tooltip } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { useMiningAsync } from 'hooks/useMiningAsync'; import { Status } from 'shared/types'; -import { useStoreState } from 'store'; +import { useStoreActions, useStoreState } from 'store'; import { Network } from 'types'; import { getNetworkBackendId } from 'utils/network'; import BalanceChannelsButton from 'components/common/BalanceChannelsButton'; +import StatusButton from 'components/common/StatusButton'; import AutoMineButton from 'components/designer/AutoMineButton'; import SyncButton from 'components/designer/SyncButton'; -import StatusButton from 'components/common/StatusButton'; const Styled = { Button: styled(Button)` @@ -52,6 +54,48 @@ const NetworkActions: React.FC = ({ const mineAsync = useMiningAsync(network); + const { notify } = useStoreActions(s => s.app); + const { toggleTorForNetwork } = useStoreActions(s => s.network); + + const handleTorToggle = useCallback( + async (checked: boolean) => { + try { + await toggleTorForNetwork({ networkId: network.id, enabled: checked }); + notify({ + message: l(checked ? 'torEnabledAll' : 'torDisabledAll'), + }); + } catch (error: any) { + notify({ message: l('torToggleError'), error }); + } + }, + [network.id], + ); + + const TorMenuSwitch = () => { + const isTorEnabled = nodes.lightning.some(n => n.enableTor); + + return ( + <> + + + {l('torTitle')} + + } + unCheckedChildren={ + <> + {l('torTitle')} + + } + /> + + + ); + }; + const handleClick: MenuProps['onClick'] = useCallback((info: { key: string }) => { switch (info.key) { case 'rename': @@ -70,6 +114,8 @@ const NetworkActions: React.FC = ({ { key: 'rename', label: l('menuRename'), icon: }, { key: 'export', label: l('menuExport'), icon: }, { key: 'delete', label: l('menuDelete'), icon: }, + { type: 'divider' }, + { key: 'tor', label: }, ]; return ( diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 4e1c9fa782..562b757a13 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -511,6 +511,12 @@ "cmps.network.NetworkActions.primaryBtnRestart": "Restart", "cmps.network.NetworkActions.mineBtn": "Quick Mine", "cmps.network.NetworkActions.mineError": "Unable to mine blocks", + "cmps.network.NetworkActions.torTitle": "Tor", + "cmps.network.NetworkActions.torToggleTooltip": "Enable/Disable Tor for all supported nodes", + "cmps.network.NetworkActions.torNotSupported": "No nodes in this network support Tor", + "cmps.network.NetworkActions.torEnabledAll": "Tor enabled for all supported nodes", + "cmps.network.NetworkActions.torDisabledAll": "Tor disabled for all nodes", + "cmps.network.NetworkActions.torToggleError": "Failed to toggle Tor settings", "cmps.network.NetworkView.missingImages": "Starting this network will take a bit longer than normal because it uses docker images that have not been downloaded yet.", "cmps.network.NetworkView.renameSave": "Save", "cmps.network.NetworkView.renameCancel": "Cancel", diff --git a/src/lib/docker/composeFile.ts b/src/lib/docker/composeFile.ts index a1e8497068..ea1875a9c4 100644 --- a/src/lib/docker/composeFile.ts +++ b/src/lib/docker/composeFile.ts @@ -13,8 +13,8 @@ import { eclairCredentials, litdCredentials, } from 'utils/constants'; -import { getContainerName, getDefaultCommand } from 'utils/network'; -import { bitcoind, clightning, eclair, litd, lnd, tapd, simln } from './nodeTemplates'; +import { applyTorFlags, getContainerName, getDefaultCommand } from 'utils/network'; +import { bitcoind, clightning, eclair, litd, lnd, simln, tapd } from './nodeTemplates'; export interface ComposeService { image: string; @@ -93,11 +93,21 @@ class ComposeFile { // use the node's custom image or the default for the implementation const image = node.docker.image || `${dockerConfigs.LND.imageName}:${version}`; // use the node's custom command or the default for the implementation - const nodeCommand = node.docker.command || getDefaultCommand('LND', version); + let nodeCommand = node.docker.command || getDefaultCommand('LND', version); + + // Add Tor flags if Tor is enabled + nodeCommand = applyTorFlags(nodeCommand, !!node.enableTor); + // replace the variables in the command const command = this.mergeCommand(nodeCommand, variables); + // add the docker service const svc = lnd(name, container, image, rest, grpc, p2p, command); + // add ENABLE_TOR variable + svc.environment = { + ...svc.environment, + ENABLE_TOR: node.enableTor ? 'true' : 'false', + }; this.addService(svc); } diff --git a/src/shared/types.ts b/src/shared/types.ts index e594f57f98..e532138df4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -18,6 +18,7 @@ export interface CommonNode { image: string; command: string; }; + enableTor?: boolean; } export interface LightningNode extends CommonNode { diff --git a/src/store/models/network.ts b/src/store/models/network.ts index a28fef4842..bfb0d701bb 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -14,12 +14,13 @@ import { TapdNode, TapNode, } from 'shared/types'; -import { AutoMineMode, CustomImage, Network, StoreInjections, Simulation } from 'types'; +import { AutoMineMode, CustomImage, Network, Simulation, StoreInjections } from 'types'; import { delay } from 'utils/async'; import { initChartFromNetwork } from 'utils/chart'; import { APP_VERSION, DOCKER_REPO } from 'utils/constants'; import { rm } from 'utils/files'; import { + applyTorFlags, createBitcoindNetworkNode, createCLightningNetworkNode, createEclairNetworkNode, @@ -236,6 +237,14 @@ export interface NetworkModel { RootModel, Promise >; + setLightningNodesTor: Action; + toggleTorForNetwork: Thunk< + NetworkModel, + { networkId: number; enabled: boolean }, + StoreInjections, + RootModel, + Promise + >; } const networkModel: NetworkModel = { @@ -459,7 +468,20 @@ const networkModel: NetworkModel = { const networks = getState().networks; let network = networks.find(n => n.id === node.networkId); if (!network) throw new Error(l('networkByIdErr', { networkId: node.networkId })); - actions.updateNodeCommand({ id: node.networkId, name: node.name, command }); + + let cleanCommand = command; + if (node.type === 'lightning') { + const lnNode = node as LightningNode; + if (lnNode.implementation === 'LND') { + cleanCommand = applyTorFlags(command, false); + } + } + + actions.updateNodeCommand({ + id: node.networkId, + name: node.name, + command: cleanCommand, + }); await actions.save(); network = getState().networks.find(n => n.id === node.networkId) as Network; await injections.dockerService.saveComposeFile(network); @@ -1270,6 +1292,29 @@ const networkModel: NetworkModel = { throw e; } }), + setLightningNodesTor: action((state, { networkId, enabled }) => { + const network = state.networks.find(n => n.id === networkId); + if (network) { + network.nodes.lightning.forEach(node => { + node.enableTor = enabled; + }); + } + }), + toggleTorForNetwork: thunk( + async (actions, { networkId, enabled }, { getState, injections }) => { + const networks = getState().networks; + let network = networks.find(n => n.id === networkId); + if (!network) throw new Error(l('networkByIdErr', { networkId })); + + actions.setLightningNodesTor({ networkId, enabled }); + + await actions.save(); + console.log('About to save compose file...'); + network = getState().networks.find(n => n.id === networkId) as Network; + await injections.dockerService.saveComposeFile(network); + console.log('Compose file saved'); + }, + ), }; export default networkModel; diff --git a/src/utils/network.ts b/src/utils/network.ts index 25f1bda493..e52875a490 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -690,6 +690,58 @@ export const renameNode = async (network: Network, node: AnyNode, newName: strin } }; +/** + * Adds or removes Tor flags from an LND command + */ +export const applyTorFlags = (command: string, enableTor: boolean): string => { + const torFlags = [ + '--tor.active', + '--tor.socks=127.0.0.1:9050', + '--tor.control=127.0.0.1:9051', + '--tor.v3', + ]; + + // Remove existing Tor flags to avoid duplicates + const lines = command + .split('\n') + .filter(line => !torFlags.some(flag => line.trim().startsWith(flag))); + + let cleanCommand = lines.join('\n').trim(); + + // Add Tor flags if enabled + if (enableTor) { + const torFlagsStr = torFlags.join('\n '); + cleanCommand = `${cleanCommand}\n ${torFlagsStr}`; + } + + return cleanCommand; +}; + +export const getEffectiveCommand = (node: CommonNode): string => { + let implementation: NodeImplementation; + if (node.type === 'lightning') { + implementation = (node as LightningNode).implementation; + } else if (node.type === 'bitcoin') { + implementation = (node as BitcoinNode).implementation; + } else if (node.type === 'tap') { + implementation = (node as TapNode).implementation; + } else { + return node.docker.command; + } + + let command = node.docker.command || getDefaultCommand(implementation, node.version); + + // Add Tor flags for LND nodes if enabled + if (node.type === 'lightning') { + const lnNode = node as LightningNode; + if (lnNode.implementation === 'LND' && lnNode.enableTor) { + command = applyTorFlags(command, !!lnNode.enableTor); + } + } + + return command; +}; + /** * Returns the images needed to start a network that are not included in the list * of images already pulled From d5905fed4da8e40b017197abfa31810d787ec413 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Fri, 12 Dec 2025 14:09:10 +0300 Subject: [PATCH 02/12] feat(tor-support): add tor icon for tor enabled nodes --- src/components/designer/custom/NodeInner.tsx | 11 +++++++++- src/resources/onion.png | Bin 0 -> 78389 bytes src/store/models/designer.ts | 22 +++++++++++++++++++ src/store/models/network.ts | 2 -- src/utils/chart.ts | 1 + 5 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 src/resources/onion.png diff --git a/src/components/designer/custom/NodeInner.tsx b/src/components/designer/custom/NodeInner.tsx index 01be90b190..bc74e263ee 100644 --- a/src/components/designer/custom/NodeInner.tsx +++ b/src/components/designer/custom/NodeInner.tsx @@ -2,11 +2,12 @@ import React from 'react'; import styled from '@emotion/styled'; import { INodeInnerDefaultProps, ISize } from '@mrblenny/react-flow-chart'; import { useTheme } from 'hooks/useTheme'; +import { useStoreState } from 'store'; import { ThemeColors } from 'theme/colors'; import { LOADING_NODE_ID } from 'utils/constants'; import { Loader, StatusBadge } from 'components/common'; +import torIcon from '../../../resources/onion.png'; import NodeContextMenu from '../NodeContextMenu'; -import { useStoreState } from 'store'; const Styled = { Node: styled.div<{ size?: ISize; colors: ThemeColors['node']; isSelected: boolean }>` @@ -26,6 +27,13 @@ const Styled = { cursor: grab; } `, + TorIcon: styled.img` + position: absolute; + width: 20px; + height: 20px; + bottom: -8px; + right: 0; + `, }; const CustomNodeInner: React.FC = ({ node }) => { @@ -45,6 +53,7 @@ const CustomNodeInner: React.FC = ({ node }) => { + {node.properties.tor && } ); diff --git a/src/resources/onion.png b/src/resources/onion.png new file mode 100644 index 0000000000000000000000000000000000000000..364072b23ef0441736e64a424139f026d27e25b2 GIT binary patch literal 78389 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 onLinkCompleteListener: ThunkOn; onCanvasDropListener: ThunkOn; + onNetworkToggleTor: ActionOn; zoomIn: Action; zoomOut: Action; zoomReset: Action; @@ -604,6 +605,27 @@ const designerModel: DesignerModel = { chart.offset = snap({ x: data.positionX, y: data.positionY }, config); chart.scale = data.scale; }), + onNetworkToggleTor: actionOn( + (actions, storeActions) => storeActions.network.toggleTorForNetwork, + (state, { payload }) => { + const { networkId, enabled } = payload; + const chart = state.allCharts[networkId]; + + if (chart) { + // Update all lightning nodes' tor property in the chart + Object.keys(chart.nodes).forEach(nodeName => { + const node = chart.nodes[nodeName]; + // Only update lightning nodes + if (node.type === 'lightning') { + node.properties = { + ...node.properties, + tor: enabled, + }; + } + }); + } + }, + ), }; export default designerModel; diff --git a/src/store/models/network.ts b/src/store/models/network.ts index bfb0d701bb..caee641771 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -1309,10 +1309,8 @@ const networkModel: NetworkModel = { actions.setLightningNodesTor({ networkId, enabled }); await actions.save(); - console.log('About to save compose file...'); network = getState().networks.find(n => n.id === networkId) as Network; await injections.dockerService.saveComposeFile(network); - console.log('Compose file saved'); }, ), }; diff --git a/src/utils/chart.ts b/src/utils/chart.ts index d99038f0dc..9c0d282522 100644 --- a/src/utils/chart.ts +++ b/src/utils/chart.ts @@ -66,6 +66,7 @@ export const createLightningChartNode = (ln: LightningNode, yOffset = 0) => { properties: { status: ln.status, icon: dockerConfigs[ln.implementation].logo, + tor: ln.enableTor, }, }; From 29eb8884a7a3b4c61fe9518a63474a689f880d85 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sun, 14 Dec 2025 00:03:01 +0300 Subject: [PATCH 03/12] feat(tor-support): add Tor support for Bitcoin nodes Extends existing Tor functionality to Bitcoin Core nodes. Refactors applyTorFlags to handle multiple implementations and updates Docker compose generation to conditionally apply bitcoind Tor flags based on node.enableTor setting. --- docker/bitcoind/Dockerfile | 4 +- docker/bitcoind/docker-entrypoint.sh | 33 ++++++++++++++++ src/lib/docker/composeFile.ts | 14 ++++++- src/store/models/designer.ts | 5 +++ src/store/models/network.ts | 16 ++++++-- src/utils/chart.ts | 1 + src/utils/network.ts | 57 +++++++++++++++++++++++----- 7 files changed, 113 insertions(+), 17 deletions(-) diff --git a/docker/bitcoind/Dockerfile b/docker/bitcoind/Dockerfile index 04e8a071f5..1957c5aa1d 100644 --- a/docker/bitcoind/Dockerfile +++ b/docker/bitcoind/Dockerfile @@ -4,7 +4,7 @@ ARG BITCOIN_VERSION ENV PATH=/opt/bitcoin-${BITCOIN_VERSION}/bin:$PATH RUN apt-get update -y \ - && apt-get install -y curl gosu \ + && apt-get install -y curl gosu tor\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -26,7 +26,7 @@ RUN chmod a+x /entrypoint.sh VOLUME ["/home/bitcoin/.bitcoin"] -EXPOSE 18443 18444 28334 28335 +EXPOSE 18443 18444 28334 28335 9050 9051 ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/bitcoind/docker-entrypoint.sh b/docker/bitcoind/docker-entrypoint.sh index 8079e99be2..3a2afdfc9d 100644 --- a/docker/bitcoind/docker-entrypoint.sh +++ b/docker/bitcoind/docker-entrypoint.sh @@ -14,6 +14,39 @@ if ! id bitcoin > /dev/null 2>&1; then chown -R $USERID:$GROUPID /home/bitcoin fi + +if [ "${ENABLE_TOR}" = "true" ]; then + if getent group debian-tor > /dev/null 2>&1; then + usermod -a -G debian-tor bitcoin + fi + + echo "Starting Tor service for Bitcoin..." + mkdir -p /var/lib/tor/bitcoin-service + chown -R debian-tor:debian-tor /var/lib/tor + chmod 700 /var/lib/tor + chmod 700 /var/lib/tor/bitcoin-service + + # Generate torrc file + cat > /etc/tor/torrc < >; - setLightningNodesTor: Action; + setAllNodesTor: Action; toggleTorForNetwork: Thunk< NetworkModel, { networkId: number; enabled: boolean }, @@ -473,7 +473,12 @@ const networkModel: NetworkModel = { if (node.type === 'lightning') { const lnNode = node as LightningNode; if (lnNode.implementation === 'LND') { - cleanCommand = applyTorFlags(command, false); + cleanCommand = applyTorFlags(command, false, 'LND'); + } + } else if (node.type === 'bitcoin') { + const btcNode = node as BitcoinNode; + if (btcNode.implementation === 'bitcoind') { + cleanCommand = applyTorFlags(command, false, 'bitcoind'); } } @@ -1292,12 +1297,15 @@ const networkModel: NetworkModel = { throw e; } }), - setLightningNodesTor: action((state, { networkId, enabled }) => { + setAllNodesTor: action((state, { networkId, enabled }) => { const network = state.networks.find(n => n.id === networkId); if (network) { network.nodes.lightning.forEach(node => { node.enableTor = enabled; }); + network.nodes.bitcoin.forEach(node => { + node.enableTor = enabled; + }); } }), toggleTorForNetwork: thunk( @@ -1306,7 +1314,7 @@ const networkModel: NetworkModel = { let network = networks.find(n => n.id === networkId); if (!network) throw new Error(l('networkByIdErr', { networkId })); - actions.setLightningNodesTor({ networkId, enabled }); + actions.setAllNodesTor({ networkId, enabled }); await actions.save(); network = getState().networks.find(n => n.id === networkId) as Network; diff --git a/src/utils/chart.ts b/src/utils/chart.ts index 9c0d282522..b045d074ba 100644 --- a/src/utils/chart.ts +++ b/src/utils/chart.ts @@ -147,6 +147,7 @@ export const createBitcoinChartNode = (btc: BitcoinNode, yOffset = 0) => { properties: { status: btc.status, icon: dockerConfigs[btc.implementation].logo, + tor: btc.enableTor, }, }; diff --git a/src/utils/network.ts b/src/utils/network.ts index e52875a490..de37563a73 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -691,15 +691,42 @@ export const renameNode = async (network: Network, node: AnyNode, newName: strin }; /** - * Adds or removes Tor flags from an LND command + * Get Tor flags for a specific implementation */ -export const applyTorFlags = (command: string, enableTor: boolean): string => { - const torFlags = [ - '--tor.active', - '--tor.socks=127.0.0.1:9050', - '--tor.control=127.0.0.1:9051', - '--tor.v3', - ]; + +const getTorFlags = (implementation: NodeImplementation): string[] => { + switch (implementation) { + case 'LND': + return [ + '--tor.active', + '--tor.socks=127.0.0.1:9050', + '--tor.control=127.0.0.1:9051', + '--tor.v3', + ]; + case 'bitcoind': + return [ + '-proxy=127.0.0.1:9050', + '-torcontrol=127.0.0.1:9051', + '-bind=127.0.0.1:8334=onion', + ]; + default: + return []; + } +}; + +/** + * Adds or removes Tor flags from a node command + */ +export const applyTorFlags = ( + command: string, + enableTor: boolean, + implementation: NodeImplementation, +): string => { + const torFlags = getTorFlags(implementation); + + if (torFlags.length === 0) { + return command; + } // Remove existing Tor flags to avoid duplicates const lines = command @@ -708,6 +735,13 @@ export const applyTorFlags = (command: string, enableTor: boolean): string => { let cleanCommand = lines.join('\n').trim(); + if (implementation === 'bitcoind') { + cleanCommand = cleanCommand.replace( + '-listenonion=0', + `-listenonion=${enableTor ? '1' : '0'}`, + ); + } + // Add Tor flags if enabled if (enableTor) { const torFlagsStr = torFlags.join('\n '); @@ -735,7 +769,12 @@ export const getEffectiveCommand = (node: CommonNode): string => { if (node.type === 'lightning') { const lnNode = node as LightningNode; if (lnNode.implementation === 'LND' && lnNode.enableTor) { - command = applyTorFlags(command, !!lnNode.enableTor); + command = applyTorFlags(command, !!lnNode.enableTor, lnNode.implementation); + } + } else if (node.type === 'bitcoin') { + const btcNode = node as BitcoinNode; + if (btcNode.implementation === 'bitcoind' && btcNode.enableTor) { + command = applyTorFlags(command, !!btcNode.enableTor, btcNode.implementation); } } From e393c93f03691d0423be1687788a727da12bea93 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sun, 14 Dec 2025 20:08:48 +0300 Subject: [PATCH 04/12] feat(tor-support): add Tor support for C-lightning nodes - Extends existing Tor functionality to Core lightning nodes. - update getInfo service to fetch tor address --- docker/clightning/Dockerfile | 4 +-- docker/clightning/docker-entrypoint.sh | 35 +++++++++++++++++++ src/lib/docker/composeFile.ts | 12 +++++-- .../lightning/clightning/clightningService.ts | 16 +++++++-- src/lib/lightning/clightning/types.ts | 6 +++- src/store/models/network.ts | 2 ++ src/utils/network.ts | 28 ++++++++++++++- 7 files changed, 94 insertions(+), 9 deletions(-) diff --git a/docker/clightning/Dockerfile b/docker/clightning/Dockerfile index c36d95d338..090df4d9ee 100644 --- a/docker/clightning/Dockerfile +++ b/docker/clightning/Dockerfile @@ -7,7 +7,7 @@ ARG CLN_VERSION # install gosu RUN apt-get update -y \ - && apt-get install -y curl gosu git \ + && apt-get install -y curl gosu git tor\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -26,7 +26,7 @@ RUN chmod a+x /entrypoint.sh VOLUME ["/home/clightning"] -EXPOSE 9735 9835 8080 10000 +EXPOSE 9735 9835 8080 10000 9050 9051 ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/clightning/docker-entrypoint.sh b/docker/clightning/docker-entrypoint.sh index 5e472e076a..2b8b3565c1 100644 --- a/docker/clightning/docker-entrypoint.sh +++ b/docker/clightning/docker-entrypoint.sh @@ -19,6 +19,41 @@ if ! id clightning > /dev/null 2>&1; then chown -R $USERID:$GROUPID /home/clightning fi + +if [ "${ENABLE_TOR}" = "true" ]; then + if getent group debian-tor > /dev/null 2>&1; then + usermod -a -G debian-tor clightning + fi + + echo "Starting Tor service for Clightning..." + mkdir -p /var/lib/tor/clightning-service + chown -R debian-tor:debian-tor /var/lib/tor + chmod 700 /var/lib/tor + chmod 700 /var/lib/tor/clightning-service + + # Generate torrc file + cat > /etc/tor/torrc < { const info = await httpPost(node, 'getinfo'); + + let rpcUrl = ''; + const torAddr = info.address?.find(a => a.type === 'torv3'); + if (torAddr) { + rpcUrl = `${info.id}@${torAddr.address}:${torAddr.port}`; + } + + if (!rpcUrl) { + rpcUrl = info.binding + .filter(b => b.type === 'ipv4') + .reduce((v, b) => `${info.id}@${node.name}:${b.port}`, ''); + } return { pubkey: info.id, alias: info.alias, - rpcUrl: info.binding - .filter(b => b.type === 'ipv4') - .reduce((v, b) => `${info.id}@${node.name}:${b.port}`, ''), + rpcUrl, syncedToChain: !info.warningBitcoindSync && !info.warningLightningdSync, blockHeight: info.blockheight, numActiveChannels: info.numActiveChannels, diff --git a/src/lib/lightning/clightning/types.ts b/src/lib/lightning/clightning/types.ts index 33f5fc8cb1..6fc8b8d0da 100644 --- a/src/lib/lightning/clightning/types.ts +++ b/src/lib/lightning/clightning/types.ts @@ -11,7 +11,11 @@ export interface GetInfoResponse { numPendingChannels: number; numActiveChannels: number; numInactiveChannels: number; - address: string[]; + address: { + type: string; + address: string; + port: number; + }[]; binding: { type: string; address: string; diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 1a291da9e5..e8ce0d9218 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -474,6 +474,8 @@ const networkModel: NetworkModel = { const lnNode = node as LightningNode; if (lnNode.implementation === 'LND') { cleanCommand = applyTorFlags(command, false, 'LND'); + } else if (lnNode.implementation === 'c-lightning') { + cleanCommand = applyTorFlags(command, false, 'c-lightning'); } } else if (node.type === 'bitcoin') { const btcNode = node as BitcoinNode; diff --git a/src/utils/network.ts b/src/utils/network.ts index de37563a73..1cdfc6aa68 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -703,6 +703,13 @@ const getTorFlags = (implementation: NodeImplementation): string[] => { '--tor.control=127.0.0.1:9051', '--tor.v3', ]; + case 'c-lightning': + return [ + '--bind-addr=127.0.0.1:9735', + '--announce-addr=statictor:127.0.0.1:9051', + '--proxy=127.0.0.1:9050', + '--always-use-proxy=true', + ]; case 'bitcoind': return [ '-proxy=127.0.0.1:9050', @@ -729,10 +736,27 @@ export const applyTorFlags = ( } // Remove existing Tor flags to avoid duplicates - const lines = command + let lines = command .split('\n') .filter(line => !torFlags.some(flag => line.trim().startsWith(flag))); + if (implementation === 'LND' && enableTor) { + lines = lines.filter(line => { + const trimmed = line.trim(); + // Remove clearnet listen and externalip when Tor is active + return !( + trimmed.startsWith('--listen=0.0.0.0') || trimmed.startsWith('--externalip=') + ); + }); + } + + if (implementation === 'c-lightning' && enableTor) { + lines = lines.filter(line => { + const trimmed = line.trim(); + // Remove clearnet addr bindings, but keep internal Docker addr + return !(trimmed.startsWith('--addr=') && !trimmed.includes('statictor')); + }); + } let cleanCommand = lines.join('\n').trim(); if (implementation === 'bitcoind') { @@ -770,6 +794,8 @@ export const getEffectiveCommand = (node: CommonNode): string => { const lnNode = node as LightningNode; if (lnNode.implementation === 'LND' && lnNode.enableTor) { command = applyTorFlags(command, !!lnNode.enableTor, lnNode.implementation); + } else if (lnNode.implementation === 'c-lightning' && lnNode.enableTor) { + command = applyTorFlags(command, !!lnNode.enableTor, lnNode.implementation); } } else if (node.type === 'bitcoin') { const btcNode = node as BitcoinNode; From 009d2e09359d522ad3287409606f033cda4ce604 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Tue, 16 Dec 2025 23:49:39 +0300 Subject: [PATCH 05/12] test: add test coverage for Tor Network Toggle Add test coverage for Tor network toggle functionality, environment variables, and edge cases for various node types. --- .../network/NetworkActions.spec.tsx | 45 ++++- src/lib/docker/composeFile.spec.ts | 43 +++++ src/lib/lightning/lnd/lndService.spec.ts | 33 ++++ src/store/models/designer.spec.ts | 39 +++++ src/store/models/network.spec.ts | 124 +++++++++++++ src/utils/network.spec.ts | 165 ++++++++++++++++++ 6 files changed, 447 insertions(+), 2 deletions(-) diff --git a/src/components/network/NetworkActions.spec.tsx b/src/components/network/NetworkActions.spec.tsx index 5e498198a0..33177ffba8 100644 --- a/src/components/network/NetworkActions.spec.tsx +++ b/src/components/network/NetworkActions.spec.tsx @@ -3,25 +3,32 @@ import { fireEvent, waitFor } from '@testing-library/react'; import { Status } from 'shared/types'; import { initChartFromNetwork } from 'utils/chart'; import { + bitcoinServiceMock, defaultStateBalances, defaultStateInfo, getNetwork, + injections, lightningServiceMock, renderWithProviders, - bitcoinServiceMock, } from 'utils/tests'; import NetworkActions from './NetworkActions'; +const dockerServiceMock = injections.dockerService as jest.Mocked< + typeof injections.dockerService +>; + describe('NetworkActions Component', () => { const handleClick = jest.fn(); const handleRenameClick = jest.fn(); const handleDeleteClick = jest.fn(); const handleExportClick = jest.fn(); - const renderComponent = (status: Status) => { + const renderComponent = (status: Status, enableTor = false) => { const network = getNetwork(1, 'test network', status); network.nodes.bitcoin.forEach(n => (n.status = status)); const chart = initChartFromNetwork(network); + network.nodes.lightning.forEach(n => (n.enableTor = enableTor)); + const initialState = { network: { networks: [network], @@ -164,4 +171,38 @@ describe('NetworkActions Component', () => { expect(lightningServiceMock.getChannels).toBeCalledTimes(4); }); }); + + describe('Tor Toggle', () => { + it('should call toggleTorForNetwork when tor switch is toggled on', async () => { + const { findByText, getByLabelText, getByRole } = renderComponent( + Status.Stopped, + false, + ); + fireEvent.mouseOver(getByLabelText('more')); + await findByText('Rename'); + fireEvent.click(getByRole('switch')); + expect(await findByText('Tor enabled for all supported nodes')).toBeInTheDocument(); + }); + + it('should call toggleTorForNetwork when tor switch is toggled off', async () => { + const { getByRole, findByText, getByLabelText } = renderComponent( + Status.Stopped, + true, + ); + fireEvent.mouseOver(getByLabelText('more')); + await findByText('Rename'); + fireEvent.click(getByRole('switch')); + expect(await findByText('Tor disabled for all nodes')).toBeInTheDocument(); + }); + + it('should display an error if toggling tor fails', async () => { + dockerServiceMock.saveComposeFile.mockRejectedValue(new Error('tor-failed')); + const { getByRole, findByText, getByLabelText } = renderComponent(Status.Stopped); + fireEvent.mouseOver(getByLabelText('more')); + await findByText('Rename'); + fireEvent.click(getByRole('switch')); + expect(await findByText('Failed to toggle Tor settings')).toBeInTheDocument(); + expect(await findByText('tor-failed')).toBeInTheDocument(); + }); + }); }); diff --git a/src/lib/docker/composeFile.spec.ts b/src/lib/docker/composeFile.spec.ts index c33d0af4d2..820dab60ba 100644 --- a/src/lib/docker/composeFile.spec.ts +++ b/src/lib/docker/composeFile.spec.ts @@ -205,4 +205,47 @@ describe('ComposeFile', () => { expect(service.container_name).toEqual('polar-n1-simln'); expect(service.command).toBe(''); }); + + it('should set ENABLE_TOR to true when tor is enabled on lnd node', () => { + lndNode.enableTor = true; + composeFile.addLnd(lndNode, btcNode); + composeFile.addBitcoind(btcNode); + const service = composeFile.content.services['alice']; + expect(service.environment?.ENABLE_TOR).toBe('true'); + }); + + it('should set ENABLE_TOR to false when tor is disabled on lnd node', () => { + lndNode.enableTor = false; + composeFile.addLnd(lndNode, btcNode); + const service = composeFile.content.services['alice']; + expect(service.environment?.ENABLE_TOR).toBe('false'); + }); + + it('should set ENABLE_TOR to true when tor is enabled on bitcoind node', () => { + btcNode.enableTor = true; + composeFile.addBitcoind(btcNode); + const service = composeFile.content.services['backend1']; + expect(service.environment?.ENABLE_TOR).toBe('true'); + }); + + it('should set ENABLE_TOR to false when tor is disabled on bitcoind node', () => { + btcNode.enableTor = false; + composeFile.addBitcoind(btcNode); + const service = composeFile.content.services['backend1']; + expect(service.environment?.ENABLE_TOR).toBe('false'); + }); + + it('should set ENABLE_TOR to true when tor is enabled on c-lightning node', () => { + clnNode.enableTor = true; + composeFile.addClightning(clnNode, btcNode); + const service = composeFile.content.services['bob']; + expect(service.environment?.ENABLE_TOR).toBe('true'); + }); + + it('should set ENABLE_TOR to false when tor is disabled on c-lightning node', () => { + clnNode.enableTor = false; + composeFile.addClightning(clnNode, btcNode); + const service = composeFile.content.services['bob']; + expect(service.environment?.ENABLE_TOR).toBe('false'); + }); }); diff --git a/src/lib/lightning/lnd/lndService.spec.ts b/src/lib/lightning/lnd/lndService.spec.ts index fe69438706..a8f3923b08 100644 --- a/src/lib/lightning/lnd/lndService.spec.ts +++ b/src/lib/lightning/lnd/lndService.spec.ts @@ -38,6 +38,39 @@ describe('LndService', () => { expect(actual).toEqual(expected); }); + it('should prefer onion rpcUrl when tor is enabled', async () => { + const torNode = { ...node, enableTor: true }; + lndProxyClient.getInfo = jest.fn().mockResolvedValue( + defaultLndInfo({ + uris: ['asdf@1.1.1.1:9735', 'asdf@xyz.onion:9735'], + }), + ); + const info = await lndService.getInfo(torNode); + expect(info.rpcUrl).toBe('asdf@xyz.onion:9735'); + }); + + it('uses clearnet address when tor is disabled', async () => { + const clearnetNode = { ...node, enableTor: false }; + lndProxyClient.getInfo = jest.fn().mockResolvedValue( + defaultLndInfo({ + uris: ['asdf@1.1.1.1:9735'], + }), + ); + const info = await lndService.getInfo(clearnetNode); + expect(info.rpcUrl).toBe('asdf@1.1.1.1:9735'); + }); + + it('falls back to clearnet when no onion address exists', async () => { + const torNode = { ...node, enableTor: true }; + lndProxyClient.getInfo = jest.fn().mockResolvedValue( + defaultLndInfo({ + uris: ['asdf@1.1.1.1:9735'], + }), + ); + const info = await lndService.getInfo(torNode); + expect(info.rpcUrl).toBe('asdf@1.1.1.1:9735'); + }); + it('should get wallet balance', async () => { const apiResponse = defaultLndWalletBalance({ confirmedBalance: '1000' }); const expected = defaultStateBalances({ confirmed: '1000' }); diff --git a/src/store/models/designer.spec.ts b/src/store/models/designer.spec.ts index 32e4fedd9b..37a11100a3 100644 --- a/src/store/models/designer.spec.ts +++ b/src/store/models/designer.spec.ts @@ -983,5 +983,44 @@ describe('Designer model', () => { expect(firstChart().scale).toEqual(2); }); }); + + describe('Network Tor', () => { + const getChart = () => store.getState().designer.allCharts[firstNetwork().id]; + const getLightningNodes = () => + Object.values(getChart().nodes).filter(n => n.type === 'lightning'); + const getBitcoinNodes = () => + Object.values(getChart().nodes).filter(n => n.type === 'bitcoin'); + + it('should set tor=true on all lightning and bitcoin nodes when enabled', async () => { + const { toggleTorForNetwork } = store.getActions().network; + await toggleTorForNetwork({ + networkId: firstNetwork().id, + enabled: true, + }); + getLightningNodes().forEach(node => expect(node.properties.tor).toBe(true)); + getBitcoinNodes().forEach(node => expect(node.properties.tor).toBe(true)); + }); + + it('should set tor=false on all lightning and bitcoin nodes when disabled', async () => { + const { toggleTorForNetwork } = store.getActions().network; + await toggleTorForNetwork({ + networkId: firstNetwork().id, + enabled: false, + }); + getLightningNodes().forEach(node => expect(node.properties.tor).toBe(false)); + getBitcoinNodes().forEach(node => expect(node.properties.tor).toBe(false)); + }); + + it('should do nothing if the chart does not exist', async () => { + const { toggleTorForNetwork } = store.getActions().network; + store.getActions().designer.setAllCharts({}); + await expect( + toggleTorForNetwork({ + networkId: firstNetwork().id, + enabled: true, + }), + ).resolves.not.toThrow(); + }); + }); }); }); diff --git a/src/store/models/network.spec.ts b/src/store/models/network.spec.ts index b60125d10e..483f99475c 100644 --- a/src/store/models/network.spec.ts +++ b/src/store/models/network.spec.ts @@ -1291,6 +1291,130 @@ describe('Network model', () => { }); }); + describe('Tor for all Network', () => { + it('should enable Tor for all nodes', async () => { + const { addNetwork, setAllNodesTor } = store.getActions().network; + await addNetwork(addNetworkArgs); + + setAllNodesTor({ networkId: 1, enabled: true }); + + const network = firstNetwork(); + network.nodes.lightning.forEach(node => { + expect(node.enableTor).toBe(true); + }); + network.nodes.bitcoin.forEach(node => { + expect(node.enableTor).toBe(true); + }); + }); + + it('should disable Tor for all nodes', async () => { + const { addNetwork, setAllNodesTor } = store.getActions().network; + await addNetwork(addNetworkArgs); + + setAllNodesTor({ networkId: 1, enabled: false }); + + const network = firstNetwork(); + network.nodes.lightning.forEach(node => { + expect(node.enableTor).toBe(false); + }); + network.nodes.bitcoin.forEach(node => { + expect(node.enableTor).toBe(false); + }); + }); + + it('setAllNodesTor should do nothing if network is not found', () => { + const { setAllNodesTor } = store.getActions().network; + expect(() => { + setAllNodesTor({ networkId: 999, enabled: true }); + }).not.toThrow(); + }); + + it('toggleTorForNetwork should throw error when network is not found', async () => { + const { toggleTorForNetwork } = store.getActions().network; + await expect( + toggleTorForNetwork({ networkId: 999, enabled: true }), + ).rejects.toThrow(); + }); + + it('should update advanced options for a c-lightning node', async () => { + const { addNetwork, updateAdvancedOptions } = store.getActions().network; + await addNetwork(addNetworkArgs); + const node = firstNetwork().nodes.lightning[1]; + const command = ['--network=testnet', '--log-level=debug'].join('\n'); + + await updateAdvancedOptions({ node, command }); + + const updatedNode = store.getState().network.networks[0].nodes.lightning[1]; + expect(updatedNode.docker.command).toBe(command); + expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); + }); + + it('should update advanced options for an Eclair node', async () => { + const { addNetwork, updateAdvancedOptions } = store.getActions().network; + await addNetwork(addNetworkArgs); + const node = firstNetwork().nodes.lightning[2]; // Eclair node (carol) + const command = '-Declair.chain=testnet'; + + await updateAdvancedOptions({ node, command }); + + const updatedNode = store.getState().network.networks[0].nodes.lightning[2]; + expect(updatedNode.docker.command).toBe(command); + expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); + }); + + it('should update advanced options for a bitcoind node', async () => { + const { addNetwork, updateAdvancedOptions } = store.getActions().network; + await addNetwork(addNetworkArgs); + const node = firstNetwork().nodes.bitcoin[0]; + const command = ['-testnet', '-server'].join('\n'); + + await updateAdvancedOptions({ node, command }); + + const updatedNode = store.getState().network.networks[0].nodes.bitcoin[0]; + expect(updatedNode.docker.command).toBe(command); + expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); + }); + + it('should not modify command for tap nodes', async () => { + const { addNetwork, updateAdvancedOptions, addNode } = store.getActions().network; + await addNetwork(addNetworkArgs); + const network = firstNetwork(); + + const tapNode = await addNode({ + id: network.id, + type: 'tapd', + version: '0.3.0', + }); + + const command = '--some-flag=value'; + + await updateAdvancedOptions({ + node: tapNode, + command, + }); + + const updatedNetwork = firstNetwork(); + const updatedNode = updatedNetwork.nodes.tap.find(n => n.name === tapNode.name); + + expect(updatedNode!.docker.command).toBe(command); + }); + + it('should not apply Tor flags for bitcoin nodes with non-bitcoind implementation', async () => { + const { addNetwork, updateAdvancedOptions } = store.getActions().network; + await addNetwork(addNetworkArgs); + const btcNode = firstNetwork().nodes.bitcoin[0]; + + btcNode.implementation = 'btcd'; + const command = ['-testnet', '-server', '-rpcuser=test'].join('\n'); + + await updateAdvancedOptions({ node: btcNode, command }); + + const updatedNode = store.getState().network.networks[0].nodes.bitcoin[0]; + expect(updatedNode.docker.command).toBe(command); + expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); + }); + }); + describe('Other actions', () => { it('should remove a network', async () => { expect(store.getState().network.networks).toHaveLength(0); diff --git a/src/utils/network.spec.ts b/src/utils/network.spec.ts index c7024963a1..670650849a 100644 --- a/src/utils/network.spec.ts +++ b/src/utils/network.spec.ts @@ -13,6 +13,7 @@ import { import { Network } from 'types'; import { defaultRepoState } from './constants'; import { + applyTorFlags, createBitcoindNetworkNode, createCLightningNetworkNode, createLitdNetworkNode, @@ -20,6 +21,7 @@ import { createNetwork, createTapdNetworkNode, getCLightningFilePaths, + getEffectiveCommand, getImageCommand, getInvoicePayload, getLndFilePaths, @@ -649,4 +651,167 @@ describe('Network Utils', () => { expect(() => mapToTapd(lnd)).toThrow(`Node "${lnd.name}" is not a litd node`); }); }); + + describe('applyTorFlags', () => { + describe('LND', () => { + const baseCommand = ['--foo=bar', '--baz=qux'].join('\n'); + + it('should add tor flags when enabled', () => { + const result = applyTorFlags(baseCommand, true, 'LND'); + + expect(result).toContain('--foo=bar'); + expect(result).toContain('--tor.active'); + expect(result).toContain('--tor.socks=127.0.0.1:9050'); + }); + + it('should remove tor flags when disabled', () => { + const commandWithTor = [ + '--tor.active', + '--tor.socks=127.0.0.1:9050', + '--foo=bar', + ].join('\n'); + + const result = applyTorFlags(commandWithTor, false, 'LND'); + + expect(result).not.toContain('tor'); + expect(result).toContain('--foo=bar'); + }); + }); + + describe('c-lightning', () => { + it('should add tor flags when enabled', () => { + const command = [ + '--addr=1.2.3.4:9735', + '--addr=statictor:127.0.0.1:9051', + '--foo=bar', + ].join('\n'); + + const result = applyTorFlags(command, true, 'c-lightning'); + + expect(result).not.toContain('--addr=1.2.3.4'); + expect(result).toContain('--foo=bar'); + expect(result).toContain('--proxy=127.0.0.1:9050'); + }); + + it('should remove tor flags when disabled', () => { + const command = [ + '--proxy=127.0.0.1:9050', + '--always-use-proxy=true', + '--foo=bar', + ].join('\n'); + + const result = applyTorFlags(command, false, 'c-lightning'); + + expect(result).not.toContain('proxy'); + expect(result).toContain('--foo=bar'); + }); + }); + + describe('bitcoind', () => { + it('should enable listenonion when tor is enabled', () => { + const command = '-listenonion=0\n-foo=bar'; + const result = applyTorFlags(command, true, 'bitcoind'); + expect(result).toContain('-proxy=127.0.0.1:9050'); + }); + }); + + it('should return original command if implementation has no tor flags', () => { + const command = '--foo=bar'; + expect(applyTorFlags(command, true, 'eclair' as NodeImplementation)).toBe(command); + }); + }); + + describe('getEffectiveCommand', () => { + it('should apply tor flags for LND when enabled', () => { + const node = { + type: 'lightning', + implementation: 'LND', + enableTor: true, + version: '0.18.0', + docker: { + command: '--foo=bar', + }, + } as LightningNode; + + const result = getEffectiveCommand(node); + + expect(result).toContain('--foo=bar'); + expect(result).toContain('--tor.active'); + }); + + it('should not modify command for LND when tor is disabled', () => { + const node = { + type: 'lightning', + implementation: 'LND', + enableTor: false, + version: '0.18.0', + docker: { + command: '--foo=bar', + }, + } as LightningNode; + + const result = getEffectiveCommand(node); + + expect(result).toBe('--foo=bar'); + }); + + it('should apply tor flags for c-lightning when enabled', () => { + const node = { + type: 'lightning', + implementation: 'c-lightning', + enableTor: true, + version: '25.05', + docker: { + command: '--foo=bar', + }, + } as LightningNode; + + const result = getEffectiveCommand(node); + + expect(result).toContain('--foo=bar'); + }); + + it('should apply tor flags for bitcoind when enabled', () => { + const node = { + type: 'bitcoin', + implementation: 'bitcoind', + enableTor: true, + version: '29.0', + docker: { + command: '-listenonion=0', + }, + } as any; + const result = getEffectiveCommand(node); + expect(result).toContain('-proxy=127.0.0.1:9050'); + }); + + it('should not modify command for bitcoin when tor is disabled', () => { + const node = { + type: 'bitcoin', + implementation: 'bitcoind', + enableTor: false, + version: '29.0', + docker: { + command: '-listenonion=0', + }, + } as any; + + const result = getEffectiveCommand(node); + + expect(result).toBe('-listenonion=0'); + }); + + it('should return command as-is for custom node types', () => { + const node = { + type: 'unknown', + docker: { + command: '--custom-command', + }, + } as any; + + const result = getEffectiveCommand(node); + + expect(result).toBe('--custom-command'); + }); + }); }); From 00af5c297e3476b180fa3561ee5d75b0529612f7 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sun, 21 Dec 2025 16:40:25 +0300 Subject: [PATCH 06/12] feat: add per-node Tor toggle functionality Add ability to enable/disable Tor for individual nodes through context menu. When toggling Tor on a running node, automatically stops, updates settings, and restarts the node. Includes UI updates to show Tor status with icon. --- src/components/common/TorButton.spec.tsx | 180 ++++++++++++++++++ src/components/common/TorButton.tsx | 91 +++++++++ src/components/common/index.ts | 1 + src/components/designer/NodeContextMenu.tsx | 6 +- .../designer/bitcoin/ActionsTab.tsx | 4 +- .../designer/lightning/ActionsTab.tsx | 2 + src/i18n/locales/en-US.json | 12 ++ src/lib/lightning/lnd/lndService.spec.ts | 33 ---- src/store/models/designer.spec.ts | 44 +++-- src/store/models/designer.ts | 28 ++- src/store/models/network.spec.ts | 101 ++++++++++ src/store/models/network.ts | 58 ++++++ 12 files changed, 505 insertions(+), 55 deletions(-) create mode 100644 src/components/common/TorButton.spec.tsx create mode 100644 src/components/common/TorButton.tsx diff --git a/src/components/common/TorButton.spec.tsx b/src/components/common/TorButton.spec.tsx new file mode 100644 index 0000000000..b9f23f1977 --- /dev/null +++ b/src/components/common/TorButton.spec.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { Status } from 'shared/types'; +import { DockerLibrary } from 'types'; +import { initChartFromNetwork } from 'utils/chart'; +import { + getNetwork, + injections, + lightningServiceMock, + renderWithProviders, + suppressConsoleErrors, +} from 'utils/tests'; +import TorButton from './TorButton'; + +const dockerServiceMock = injections.dockerService as jest.Mocked; + +describe('TorButton', () => { + const renderComponent = ( + status?: Status, + enableTor = false, + menuType?: 'enable' | 'disable', + ) => { + const network = getNetwork(1, 'test network', status); + const initialState = { + network: { + networks: [network], + }, + designer: { + allCharts: { + 1: initChartFromNetwork(network), + }, + activeId: 1, + }, + }; + const { lightning } = network.nodes; + const node = lightning[0]; + node.enableTor = enableTor; + const cmp = ; + const result = renderWithProviders(cmp, { initialState, wrapForm: true }); + return { + ...result, + node, + }; + }; + + beforeEach(() => { + lightningServiceMock.waitUntilOnline.mockResolvedValue(); + }); + + it('should display the UI elements', () => { + const { getByText } = renderComponent(); + expect(getByText('Tor')).toBeInTheDocument(); + expect(getByText('Enable Tor')).toBeInTheDocument(); + expect(getByText('Disable Tor')).toBeInTheDocument(); + }); + + it('should have Enable button disabled when Tor is already enabled', () => { + const { getByText } = renderComponent(Status.Stopped, true); + expect(getByText('Enable Tor').parentElement).toHaveAttribute('disabled'); + }); + + it('should have Disable button disabled when Tor is disabled', () => { + const { getByText } = renderComponent(Status.Stopped, false); + expect(getByText('Disable Tor').parentElement).toHaveAttribute('disabled'); + }); + + it('should show the enable Tor modal for stopped node', async () => { + const { getByText, findByText } = renderComponent(Status.Stopped, false); + fireEvent.click(getByText('Enable Tor')); + expect( + await findByText('Would you like to enable Tor for the alice node?'), + ).toBeInTheDocument(); + expect(getByText('Yes')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + }); + + it('should show the disable Tor modal for stopped node', async () => { + const { getByText, findByText } = renderComponent(Status.Stopped, true); + fireEvent.click(getByText('Disable Tor')); + expect( + await findByText('Are you sure you want to disable Tor for the alice node?'), + ).toBeInTheDocument(); + expect(getByText('Yes')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + }); + + it('should show warning alert when enabling Tor on started node', async () => { + const { getByText, findByText } = renderComponent(Status.Started, false); + fireEvent.click(getByText('Enable Tor')); + expect( + await findByText('Would you like to enable Tor for the alice node?'), + ).toBeInTheDocument(); + expect( + getByText('This node will be restarted to perform this operation'), + ).toBeInTheDocument(); + const confirmBtn = getByText('Yes'); + expect(confirmBtn.parentElement).toHaveClass('ant-btn-dangerous'); + }); + + it('should show warning alert when disabling Tor on started node', async () => { + const { getByText, findByText } = renderComponent(Status.Started, true); + fireEvent.click(getByText('Disable Tor')); + expect( + await findByText('Are you sure you want to disable Tor for the alice node?'), + ).toBeInTheDocument(); + expect( + getByText('This node will be restarted to perform this operation'), + ).toBeInTheDocument(); + }); + + it('should enable Tor when stopped', async () => { + const { getByText, findByText, getByLabelText } = renderComponent( + Status.Stopped, + false, + ); + fireEvent.click(getByText('Enable Tor')); + fireEvent.click(await findByText('Yes')); + await waitFor(() => getByLabelText('check-circle')); + expect(getByText('Tor has been enabled for the node alice')).toBeInTheDocument(); + expect(dockerServiceMock.saveComposeFile).toBeCalledTimes(1); + }); + + it('should enable Tor when started', async () => { + const { getByText, findByText, getByLabelText } = renderComponent( + Status.Started, + false, + ); + fireEvent.click(getByText('Enable Tor')); + fireEvent.click(await findByText('Yes')); + await waitFor(() => getByLabelText('check-circle')); + expect(getByText('Tor has been enabled for the node alice')).toBeInTheDocument(); + expect(dockerServiceMock.saveComposeFile).toHaveBeenCalled(); + }); + + it('should display an error if enabling Tor fails', async () => { + await suppressConsoleErrors(async () => { + dockerServiceMock.saveComposeFile.mockRejectedValue(new Error('enable-error')); + const { getByText, findByText, getByLabelText } = renderComponent( + Status.Stopped, + false, + ); + fireEvent.click(getByText('Enable Tor')); + fireEvent.click(await findByText('Yes')); + await waitFor(() => getByLabelText('close')); + expect(getByText('Failed to enable Tor for this node')).toBeInTheDocument(); + expect(getByText('enable-error')).toBeInTheDocument(); + }); + }); + + it('should display an error if disabling Tor fails', async () => { + await suppressConsoleErrors(async () => { + dockerServiceMock.saveComposeFile.mockRejectedValue(new Error('disable-error')); + const { getByText, findByText, getByLabelText } = renderComponent( + Status.Stopped, + true, + ); + fireEvent.click(getByText('Disable Tor')); + fireEvent.click(await findByText('Yes')); + await waitFor(() => getByLabelText('close')); + expect(getByText('Failed to disable Tor for this node')).toBeInTheDocument(); + expect(getByText('disable-error')).toBeInTheDocument(); + }); + }); + + it('should show enable modal when menu item is clicked', async () => { + const { getByText, findByText } = renderComponent(Status.Stopped, false, 'enable'); + fireEvent.click(getByText('Enable Tor')); + expect( + await findByText('Would you like to enable Tor for the alice node?'), + ).toBeInTheDocument(); + }); + + it('should show disable modal when menu item is clicked', async () => { + const { getByText, findByText } = renderComponent(Status.Stopped, true, 'disable'); + fireEvent.click(getByText('Disable Tor')); + expect( + await findByText('Are you sure you want to disable Tor for the alice node?'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/TorButton.tsx b/src/components/common/TorButton.tsx new file mode 100644 index 0000000000..4cc869db56 --- /dev/null +++ b/src/components/common/TorButton.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { LockOutlined, UnlockOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Alert, Button, Form, Modal } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { CommonNode, Status } from 'shared/types'; +import { useStoreActions } from 'store'; + +interface Props { + node: CommonNode; + menuType?: 'enable' | 'disable'; +} + +const Styled = { + Button: styled(Button)` + width: 50%; + `, +}; + +const TorButton: React.FC = ({ node, menuType }) => { + const { l } = usePrefixedTranslation('cmps.common.TorButton'); + const { notify } = useStoreActions(s => s.app); + const { toggleTorForNode } = useStoreActions(s => s.network); + + if (node.type === 'tap') { + return null; + } + + const disabled = [Status.Starting, Status.Stopping].includes(node.status); + const isStarted = node.status === Status.Started; + + const isTorEnabled = + (node.type === 'lightning' || node.type === 'bitcoin') && !!node.enableTor; + + const showConfirmModal = (mode: string) => { + const { name } = node; + const enable = mode === 'enable'; + + Modal.confirm({ + title: l(`${mode}ConfirmTitle`, { name }), + content: isStarted ? : null, + okText: l(`confirmBtn`), + okType: isStarted ? 'danger' : 'primary', + cancelText: l(`cancelBtn`), + onOk: async () => { + try { + await toggleTorForNode({ node, enabled: enable }); + notify({ message: l(`${mode}Success`, { name }) }); + } catch (error: any) { + notify({ message: l(`${mode}Error`), error }); + throw error; + } + }, + }); + }; + + // render a menu item inside of the NodeContextMenu + if (menuType) { + const icon = menuType === 'disable' ? : ; + + return ( +
showConfirmModal(menuType)}> + {icon} + {l(`${menuType}Btn`)} +
+ ); + } + + return ( + + + showConfirmModal('disable')} + > + + {l('disableBtn')} + + showConfirmModal('enable')} + > + + {l('enableBtn')} + + + + ); +}; + +export default TorButton; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 784fe6ecf2..cc362800b3 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -9,3 +9,4 @@ export { default as StatusTag } from './StatusTag'; export { default as RemoveNode } from './RemoveNode'; export { default as RenameNodeModal } from './RenameNodeModal'; export { default as RenameNodeButton } from './RenameNodeButton'; +export { default as TorButton } from './TorButton'; diff --git a/src/components/designer/NodeContextMenu.tsx b/src/components/designer/NodeContextMenu.tsx index af62427b2d..1dba46920f 100644 --- a/src/components/designer/NodeContextMenu.tsx +++ b/src/components/designer/NodeContextMenu.tsx @@ -6,8 +6,9 @@ import { useStoreState } from 'store'; import { AdvancedOptionsButton, RemoveNode, - RestartNode, RenameNodeButton, + RestartNode, + TorButton, } from 'components/common'; import { ViewLogsButton } from 'components/dockerLogs'; import { OpenTerminalButton } from 'components/terminal'; @@ -47,6 +48,7 @@ const NodeContextMenu: React.FC = ({ node: { id }, children }) => { const isLN = node.type === 'lightning'; const isBackend = node.type === 'bitcoin'; const isStarted = node.status === Status.Started; + const isTorEnabled = node.enableTor ?? false; let items: MenuProps['items'] = []; items = items.concat( @@ -109,6 +111,8 @@ const NodeContextMenu: React.FC = ({ node: { id }, children }) => { ), addItemIf('rename', ), addItemIf('options', ), + addItemIf('enable', , !isTorEnabled), + addItemIf('disable', , isTorEnabled), addItemIf('remove', ), ); diff --git a/src/components/designer/bitcoin/ActionsTab.tsx b/src/components/designer/bitcoin/ActionsTab.tsx index 5798ddd9dd..eb541a81a3 100644 --- a/src/components/designer/bitcoin/ActionsTab.tsx +++ b/src/components/designer/bitcoin/ActionsTab.tsx @@ -5,8 +5,9 @@ import { BitcoinNode, Status } from 'shared/types'; import { AdvancedOptionsButton, RemoveNode, - RestartNode, RenameNodeButton, + RestartNode, + TorButton, } from 'components/common'; import { ViewLogsButton } from 'components/dockerLogs'; import { OpenTerminalButton } from 'components/terminal'; @@ -36,6 +37,7 @@ const ActionsTab: React.FC = ({ node }) => { )} + diff --git a/src/components/designer/lightning/ActionsTab.tsx b/src/components/designer/lightning/ActionsTab.tsx index d0e9d4fcaf..22d3559607 100644 --- a/src/components/designer/lightning/ActionsTab.tsx +++ b/src/components/designer/lightning/ActionsTab.tsx @@ -8,6 +8,7 @@ import { RemoveNode, RenameNodeButton, RestartNode, + TorButton, } from 'components/common'; import { ViewLogsButton } from 'components/dockerLogs'; import { OpenTerminalButton } from 'components/terminal'; @@ -45,6 +46,7 @@ const ActionsTab: React.FC = ({ node }) => { )} + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 562b757a13..1572e5ac19 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -71,6 +71,18 @@ "cmps.common.RemoveNode.success": "The node {{name}} has been removed from the network", "cmps.common.RemoveNode.error": "Unable to remove the node", "cmps.common.DockerContainerShutdown.shutdownMsg": "Shutting down...", + "cmps.common.TorButton.title": "Tor", + "cmps.common.TorButton.enableBtn": "Enable Tor", + "cmps.common.TorButton.disableBtn": "Disable Tor", + "cmps.common.TorButton.confirmBtn": "Yes", + "cmps.common.TorButton.cancelBtn": "Cancel", + "cmps.common.TorButton.enableConfirmTitle": "Would you like to enable Tor for the {{name}} node?", + "cmps.common.TorButton.enableSuccess": "Tor has been enabled for the node {{name}}", + "cmps.common.TorButton.enableError": "Failed to enable Tor for this node", + "cmps.common.TorButton.disableConfirmTitle": "Are you sure you want to disable Tor for the {{name}} node?", + "cmps.common.TorButton.disableSuccess": "Tor has been disabled for the node {{name}}", + "cmps.common.TorButton.alert": "This node will be restarted to perform this operation", + "cmps.common.TorButton.disableError": "Failed to disable Tor for this node", "cmps.designer.bitcoind.BitcoinDetails.info": "Info", "cmps.designer.bitcoind.BitcoinDetails.connect": "Connect", "cmps.designer.bitcoind.BitcoinDetails.actions": "Actions", diff --git a/src/lib/lightning/lnd/lndService.spec.ts b/src/lib/lightning/lnd/lndService.spec.ts index a8f3923b08..fe69438706 100644 --- a/src/lib/lightning/lnd/lndService.spec.ts +++ b/src/lib/lightning/lnd/lndService.spec.ts @@ -38,39 +38,6 @@ describe('LndService', () => { expect(actual).toEqual(expected); }); - it('should prefer onion rpcUrl when tor is enabled', async () => { - const torNode = { ...node, enableTor: true }; - lndProxyClient.getInfo = jest.fn().mockResolvedValue( - defaultLndInfo({ - uris: ['asdf@1.1.1.1:9735', 'asdf@xyz.onion:9735'], - }), - ); - const info = await lndService.getInfo(torNode); - expect(info.rpcUrl).toBe('asdf@xyz.onion:9735'); - }); - - it('uses clearnet address when tor is disabled', async () => { - const clearnetNode = { ...node, enableTor: false }; - lndProxyClient.getInfo = jest.fn().mockResolvedValue( - defaultLndInfo({ - uris: ['asdf@1.1.1.1:9735'], - }), - ); - const info = await lndService.getInfo(clearnetNode); - expect(info.rpcUrl).toBe('asdf@1.1.1.1:9735'); - }); - - it('falls back to clearnet when no onion address exists', async () => { - const torNode = { ...node, enableTor: true }; - lndProxyClient.getInfo = jest.fn().mockResolvedValue( - defaultLndInfo({ - uris: ['asdf@1.1.1.1:9735'], - }), - ); - const info = await lndService.getInfo(torNode); - expect(info.rpcUrl).toBe('asdf@1.1.1.1:9735'); - }); - it('should get wallet balance', async () => { const apiResponse = defaultLndWalletBalance({ confirmedBalance: '1000' }); const expected = defaultStateBalances({ confirmed: '1000' }); diff --git a/src/store/models/designer.spec.ts b/src/store/models/designer.spec.ts index 37a11100a3..d12c54d408 100644 --- a/src/store/models/designer.spec.ts +++ b/src/store/models/designer.spec.ts @@ -984,7 +984,7 @@ describe('Designer model', () => { }); }); - describe('Network Tor', () => { + describe('Enable Tor', () => { const getChart = () => store.getState().designer.allCharts[firstNetwork().id]; const getLightningNodes = () => Object.values(getChart().nodes).filter(n => n.type === 'lightning'); @@ -1001,25 +1001,45 @@ describe('Designer model', () => { getBitcoinNodes().forEach(node => expect(node.properties.tor).toBe(true)); }); - it('should set tor=false on all lightning and bitcoin nodes when disabled', async () => { - const { toggleTorForNetwork } = store.getActions().network; - await toggleTorForNetwork({ - networkId: firstNetwork().id, - enabled: false, - }); - getLightningNodes().forEach(node => expect(node.properties.tor).toBe(false)); - getBitcoinNodes().forEach(node => expect(node.properties.tor).toBe(false)); - }); - it('should do nothing if the chart does not exist', async () => { - const { toggleTorForNetwork } = store.getActions().network; + const { toggleTorForNetwork, toggleTorForNode } = store.getActions().network; store.getActions().designer.setAllCharts({}); + const node = firstNetwork().nodes.lightning[0]; + await expect( toggleTorForNetwork({ networkId: firstNetwork().id, enabled: true, }), ).resolves.not.toThrow(); + await expect( + toggleTorForNode({ + node, + enabled: true, + }), + ).resolves.not.toThrow(); + }); + + it('should set tor=true on a lightning node when enabled', async () => { + const { toggleTorForNode } = store.getActions().network; + const node = firstNetwork().nodes.lightning[0]; + await toggleTorForNode({ + node, + enabled: true, + }); + + const chartNode = getChart().nodes[node.name]; + expect(chartNode.properties.tor).toBe(true); + }); + + it('should set tor=true on a bitcoin node when enabled', async () => { + const { toggleTorForNode } = store.getActions().network; + const node = firstNetwork().nodes.bitcoin[0]; + + await toggleTorForNode({ node, enabled: true }); + + const chartNode = getChart().nodes[node.name]; + expect(chartNode.properties.tor).toBe(true); }); }); }); diff --git a/src/store/models/designer.ts b/src/store/models/designer.ts index 09d34fc370..40c217f25c 100644 --- a/src/store/models/designer.ts +++ b/src/store/models/designer.ts @@ -53,6 +53,7 @@ export interface DesignerModel { onLinkCompleteListener: ThunkOn; onCanvasDropListener: ThunkOn; onNetworkToggleTor: ActionOn; + onNodeToggleTor: ActionOn; zoomIn: Action; zoomOut: Action; zoomReset: Action; @@ -612,16 +613,10 @@ const designerModel: DesignerModel = { const chart = state.allCharts[networkId]; if (chart) { - // Update all lightning nodes' tor property in the chart + // Update tor property in the chart Object.keys(chart.nodes).forEach(nodeName => { const node = chart.nodes[nodeName]; - // Only update lightning nodes - if (node.type === 'lightning') { - node.properties = { - ...node.properties, - tor: enabled, - }; - } else if (node.type === 'bitcoin') { + if (node.type === 'lightning' || node.type === 'bitcoin') { node.properties = { ...node.properties, tor: enabled, @@ -631,6 +626,23 @@ const designerModel: DesignerModel = { } }, ), + onNodeToggleTor: actionOn( + (actions, storeActions) => storeActions.network.setNodeTor, + (state, { payload }) => { + const { networkId, nodeName, enabled } = payload; + const chart = state.allCharts[networkId]; + + if (chart) { + const node = chart.nodes[nodeName]; + if (node.type === 'lightning' || node.type === 'bitcoin') { + node.properties = { + ...node.properties, + tor: enabled, + }; + } + } + }, + ), }; export default designerModel; diff --git a/src/store/models/network.spec.ts b/src/store/models/network.spec.ts index 483f99475c..f1ef750cc5 100644 --- a/src/store/models/network.spec.ts +++ b/src/store/models/network.spec.ts @@ -1413,6 +1413,107 @@ describe('Network model', () => { expect(updatedNode.docker.command).toBe(command); expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); }); + + it('should enable Tor for a node', async () => { + const { addNetwork, setNodeTor } = store.getActions().network; + await addNetwork(addNetworkArgs); + + const network = firstNetwork(); + const btcNode = network.nodes.bitcoin[0]; + + setNodeTor({ networkId: 1, nodeName: btcNode.name, enabled: true }); + const updatedNetwork = firstNetwork(); + const updatedBtcNode = updatedNetwork.nodes.bitcoin[0]; + expect(updatedBtcNode.enableTor).toBe(true); + }); + + it('should fail to set node Tor with invalid network id', () => { + const { setNodeTor } = store.getActions().network; + expect(() => + setNodeTor({ networkId: 999, nodeName: 'alice', enabled: true }), + ).toThrow("Network with the id '999' was not found."); + }); + + it('should fail to set node Tor with invalid node name', async () => { + const { addNetwork, setNodeTor } = store.getActions().network; + await addNetwork(addNetworkArgs); + const network = firstNetwork(); + expect(() => + setNodeTor({ + networkId: network.id, + nodeName: 'invalid-node', + enabled: true, + }), + ).toThrow("The node 'invalid-node' was not found."); + }); + + it('should fail to call toggleTorForNode with invalid network id', async () => { + const { addNetwork, toggleTorForNode } = store.getActions().network; + await addNetwork(addNetworkArgs); + const node = { + ...firstNetwork().nodes.lightning[0], + networkId: 999, + }; + + await expect(toggleTorForNode({ node, enabled: true })).rejects.toThrow( + "Network with the id '999' was not found.", + ); + }); + + it('should enable Tor on a started lightning node and restart it', async () => { + const { addNetwork, toggleTorForNode, setStatus } = store.getActions().network; + await addNetwork(addNetworkArgs); + + // Start the node first + setStatus({ id: firstNetwork().id, status: Status.Started }); + + const node = { + ...firstNetwork().nodes.lightning[0], + networkId: 1, + }; + expect(node.status).toBe(Status.Started); + + await toggleTorForNode({ node, enabled: true }); + + const updatedNode = firstNetwork().nodes.lightning[0]; + expect(updatedNode.enableTor).toBe(true); + // Node should be restarted (Started status maintained) + expect(updatedNode.status).toBe(Status.Started); + expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); + }); + + it('should enable Tor on a stopped lightning node', async () => { + const { addNetwork, toggleTorForNode } = store.getActions().network; + await addNetwork(addNetworkArgs); + const node = { + ...firstNetwork().nodes.lightning[0], + networkId: 1, + }; + + await toggleTorForNode({ node, enabled: true }); + + const updatedNode = firstNetwork().nodes.lightning[0]; + expect(updatedNode.enableTor).toBe(true); + expect(injections.dockerService.saveComposeFile).toHaveBeenCalled(); + }); + + it('should preserve other node properties when toggling Tor', async () => { + const { addNetwork, toggleTorForNode } = store.getActions().network; + await addNetwork(addNetworkArgs); + const node = { + ...firstNetwork().nodes.lightning[0], + networkId: 1, + }; + const originalPorts = { ...node.ports }; + const originalName = node.name; + + await toggleTorForNode({ node, enabled: true }); + + const updatedNode = firstNetwork().nodes.lightning[0]; + expect(updatedNode.name).toBe(originalName); + expect(updatedNode.ports).toEqual(originalPorts); + expect(updatedNode.enableTor).toBe(true); + }); }); describe('Other actions', () => { diff --git a/src/store/models/network.ts b/src/store/models/network.ts index e8ce0d9218..b81c451c1b 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -245,6 +245,17 @@ export interface NetworkModel { RootModel, Promise >; + setNodeTor: Action< + NetworkModel, + { networkId: number; nodeName: string; enabled: boolean } + >; + toggleTorForNode: Thunk< + NetworkModel, + { node: CommonNode; enabled: boolean }, + StoreInjections, + RootModel, + Promise + >; } const networkModel: NetworkModel = { @@ -1323,6 +1334,53 @@ const networkModel: NetworkModel = { await injections.dockerService.saveComposeFile(network); }, ), + setNodeTor: action((state, { networkId, nodeName, enabled }) => { + const network = state.networks.find(n => n.id === networkId); + if (!network) throw new Error(l('networkByIdErr', { networkId })); + const lnNode = network.nodes.lightning.find(n => n.name === nodeName); + if (lnNode) { + lnNode.enableTor = enabled; + return; + } + + const btcNode = network.nodes.bitcoin.find(n => n.name === nodeName); + if (btcNode) { + btcNode.enableTor = enabled; + return; + } + throw new Error(l('nodeByNameErr', { name: nodeName })); + }), + toggleTorForNode: thunk( + async (actions, { node, enabled }, { getState, injections }) => { + const { networkId, name, status } = node; + const networks = getState().networks; + let network = networks.find(n => n.id === networkId); + if (!network) throw new Error(l('networkByIdErr', { networkId })); + + const wasStarted = status === Status.Started; + if (wasStarted) { + await actions.toggleNode(node); + } + actions.setNodeTor({ networkId, nodeName: name, enabled }); + await actions.save(); + + network = getState().networks.find(n => n.id === networkId) as Network; + await injections.dockerService.saveComposeFile(network); + + if (wasStarted) { + const updatedNetwork = getState().networks.find(n => n.id === networkId); + if (updatedNetwork) { + const updatedNode = [ + ...updatedNetwork.nodes.lightning, + ...updatedNetwork.nodes.bitcoin, + ].find(n => n.name === name); + if (updatedNode) { + await actions.toggleNode(updatedNode); + } + } + } + }, + ), }; export default networkModel; From a401ff3c7d038d9116716a2f1267086fb7381e55 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sun, 21 Dec 2025 23:46:20 +0300 Subject: [PATCH 07/12] refactor: improve Tor support UI to reflect implementation limitations - Add supportsTor() helper to check Tor compatibility - Show "not supported" message for tap nodes - Hide Tor menu items for unsupported nodes - Simplify Tor-related functions using new helper --- src/components/common/TorButton.spec.tsx | 24 ++++++++++ src/components/common/TorButton.tsx | 13 +++-- src/components/designer/NodeContextMenu.tsx | 16 +++++-- .../network/NetworkActions.spec.tsx | 5 +- src/components/network/NetworkActions.tsx | 48 +++++++++++-------- src/i18n/locales/en-US.json | 2 +- src/store/models/designer.spec.ts | 26 ++++++++++ src/store/models/network.ts | 20 ++++---- src/utils/network.ts | 22 ++++----- 9 files changed, 126 insertions(+), 50 deletions(-) diff --git a/src/components/common/TorButton.spec.tsx b/src/components/common/TorButton.spec.tsx index b9f23f1977..4139c481b9 100644 --- a/src/components/common/TorButton.spec.tsx +++ b/src/components/common/TorButton.spec.tsx @@ -177,4 +177,28 @@ describe('TorButton', () => { await findByText('Are you sure you want to disable Tor for the alice node?'), ).toBeInTheDocument(); }); + + it('should show not supported message for non-lightning/bitcoin node types', () => { + const network = getNetwork(1, 'test network'); + const tapNode = { + ...network.nodes.lightning[0], + type: 'tap', + enableTor: true, + } as any; + + const initialState = { + network: { networks: [network] }, + designer: { + allCharts: { 1: initChartFromNetwork(network) }, + activeId: 1, + }, + }; + + const { getByText } = renderWithProviders(, { + initialState, + wrapForm: true, + }); + + expect(getByText('Tor Not Currently Supported')).toBeInTheDocument(); + }); }); diff --git a/src/components/common/TorButton.tsx b/src/components/common/TorButton.tsx index 4cc869db56..6b4aa4b188 100644 --- a/src/components/common/TorButton.tsx +++ b/src/components/common/TorButton.tsx @@ -5,6 +5,7 @@ import { Alert, Button, Form, Modal } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { CommonNode, Status } from 'shared/types'; import { useStoreActions } from 'store'; +import { supportsTor } from 'utils/network'; interface Props { node: CommonNode; @@ -22,10 +23,6 @@ const TorButton: React.FC = ({ node, menuType }) => { const { notify } = useStoreActions(s => s.app); const { toggleTorForNode } = useStoreActions(s => s.network); - if (node.type === 'tap') { - return null; - } - const disabled = [Status.Starting, Status.Stopping].includes(node.status); const isStarted = node.status === Status.Started; @@ -66,6 +63,14 @@ const TorButton: React.FC = ({ node, menuType }) => { ); } + if (!supportsTor(node)) { + return ( + + + + ); + } + return ( diff --git a/src/components/designer/NodeContextMenu.tsx b/src/components/designer/NodeContextMenu.tsx index 1dba46920f..8c9693c09c 100644 --- a/src/components/designer/NodeContextMenu.tsx +++ b/src/components/designer/NodeContextMenu.tsx @@ -3,6 +3,7 @@ import { INode } from '@mrblenny/react-flow-chart'; import { Dropdown, MenuProps } from 'antd'; import { BitcoinNode, LightningNode, Status, TapNode } from 'shared/types'; import { useStoreState } from 'store'; +import { supportsTor } from 'utils/network'; import { AdvancedOptionsButton, RemoveNode, @@ -48,7 +49,8 @@ const NodeContextMenu: React.FC = ({ node: { id }, children }) => { const isLN = node.type === 'lightning'; const isBackend = node.type === 'bitcoin'; const isStarted = node.status === Status.Started; - const isTorEnabled = node.enableTor ?? false; + const isTorEnabled = (isLN || isBackend) && !!node.enableTor; + const isTorSupported = supportsTor(node); let items: MenuProps['items'] = []; items = items.concat( @@ -111,8 +113,16 @@ const NodeContextMenu: React.FC = ({ node: { id }, children }) => { ), addItemIf('rename', ), addItemIf('options', ), - addItemIf('enable', , !isTorEnabled), - addItemIf('disable', , isTorEnabled), + addItemIf( + 'enable', + , + isTorSupported && !isTorEnabled, + ), + addItemIf( + 'disable', + , + isTorSupported && isTorEnabled, + ), addItemIf('remove', ), ); diff --git a/src/components/network/NetworkActions.spec.tsx b/src/components/network/NetworkActions.spec.tsx index 33177ffba8..95b31cc48f 100644 --- a/src/components/network/NetworkActions.spec.tsx +++ b/src/components/network/NetworkActions.spec.tsx @@ -25,7 +25,10 @@ describe('NetworkActions Component', () => { const renderComponent = (status: Status, enableTor = false) => { const network = getNetwork(1, 'test network', status); - network.nodes.bitcoin.forEach(n => (n.status = status)); + network.nodes.bitcoin.forEach(n => { + n.status = status; + n.enableTor = enableTor; + }); const chart = initChartFromNetwork(network); network.nodes.lightning.forEach(n => (n.enableTor = enableTor)); diff --git a/src/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index 367cc0528b..a1f0fdb2a8 100644 --- a/src/components/network/NetworkActions.tsx +++ b/src/components/network/NetworkActions.tsx @@ -15,7 +15,7 @@ import { useMiningAsync } from 'hooks/useMiningAsync'; import { Status } from 'shared/types'; import { useStoreActions, useStoreState } from 'store'; import { Network } from 'types'; -import { getNetworkBackendId } from 'utils/network'; +import { getNetworkBackendId, supportsTor } from 'utils/network'; import BalanceChannelsButton from 'components/common/BalanceChannelsButton'; import StatusButton from 'components/common/StatusButton'; import AutoMineButton from 'components/designer/AutoMineButton'; @@ -57,6 +57,16 @@ const NetworkActions: React.FC = ({ const { notify } = useStoreActions(s => s.app); const { toggleTorForNetwork } = useStoreActions(s => s.network); + const torSupportedNodes = [ + ...network.nodes.bitcoin.filter(supportsTor), + ...network.nodes.lightning.filter(supportsTor), + ]; + + const hasTorSupportedNodes = torSupportedNodes.length > 0; + + const isTorEnabled = + hasTorSupportedNodes && torSupportedNodes.every(node => node.enableTor); + const handleTorToggle = useCallback( async (checked: boolean) => { try { @@ -72,26 +82,26 @@ const NetworkActions: React.FC = ({ ); const TorMenuSwitch = () => { - const isTorEnabled = nodes.lightning.some(n => n.enableTor); - return ( <> - - - {l('torTitle')} - - } - unCheckedChildren={ - <> - {l('torTitle')} - - } - /> - + {hasTorSupportedNodes && ( + + + {l('torTitle')} + + } + unCheckedChildren={ + <> + {l('torTitle')} + + } + /> + + )} ); }; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 1572e5ac19..02bceae351 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -83,6 +83,7 @@ "cmps.common.TorButton.disableSuccess": "Tor has been disabled for the node {{name}}", "cmps.common.TorButton.alert": "This node will be restarted to perform this operation", "cmps.common.TorButton.disableError": "Failed to disable Tor for this node", + "cmps.common.TorButton.notSupported": "Tor Not Currently Supported", "cmps.designer.bitcoind.BitcoinDetails.info": "Info", "cmps.designer.bitcoind.BitcoinDetails.connect": "Connect", "cmps.designer.bitcoind.BitcoinDetails.actions": "Actions", @@ -525,7 +526,6 @@ "cmps.network.NetworkActions.mineError": "Unable to mine blocks", "cmps.network.NetworkActions.torTitle": "Tor", "cmps.network.NetworkActions.torToggleTooltip": "Enable/Disable Tor for all supported nodes", - "cmps.network.NetworkActions.torNotSupported": "No nodes in this network support Tor", "cmps.network.NetworkActions.torEnabledAll": "Tor enabled for all supported nodes", "cmps.network.NetworkActions.torDisabledAll": "Tor disabled for all nodes", "cmps.network.NetworkActions.torToggleError": "Failed to toggle Tor settings", diff --git a/src/store/models/designer.spec.ts b/src/store/models/designer.spec.ts index d12c54d408..272af695a4 100644 --- a/src/store/models/designer.spec.ts +++ b/src/store/models/designer.spec.ts @@ -1041,6 +1041,32 @@ describe('Designer model', () => { const chartNode = getChart().nodes[node.name]; expect(chartNode.properties.tor).toBe(true); }); + + it('should not set tor property if the node type is not lightning or bitcoin', async () => { + const { toggleTorForNode } = store.getActions().network; + const network = firstNetwork(); + const chart = getChart(); + + const node = { + ...network.nodes.lightning[0], + type: 'tap', + }; + + chart.nodes[node.name] = { + ...chart.nodes[network.nodes.lightning[0].name], + id: node.name, + type: 'tap', + properties: { tor: false }, + }; + + await toggleTorForNode({ + node: node as any, + enabled: true, + }); + + const chartNode = getChart().nodes[node.name]; + expect(chartNode.properties.tor).toBe(false); + }); }); }); }); diff --git a/src/store/models/network.ts b/src/store/models/network.ts index b81c451c1b..f2dd383b49 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -34,6 +34,7 @@ import { importNetworkFromZip, OpenPorts, renameNode, + supportsTor, zipNetwork, } from 'utils/network'; import { prefixTranslation } from 'utils/translate'; @@ -481,18 +482,15 @@ const networkModel: NetworkModel = { if (!network) throw new Error(l('networkByIdErr', { networkId: node.networkId })); let cleanCommand = command; - if (node.type === 'lightning') { - const lnNode = node as LightningNode; - if (lnNode.implementation === 'LND') { - cleanCommand = applyTorFlags(command, false, 'LND'); - } else if (lnNode.implementation === 'c-lightning') { - cleanCommand = applyTorFlags(command, false, 'c-lightning'); - } - } else if (node.type === 'bitcoin') { - const btcNode = node as BitcoinNode; - if (btcNode.implementation === 'bitcoind') { - cleanCommand = applyTorFlags(command, false, 'bitcoind'); + + if (supportsTor(node)) { + let implementation: NodeImplementation; + if (node.type === 'lightning') { + implementation = (node as LightningNode).implementation; + } else { + implementation = (node as BitcoinNode).implementation; } + cleanCommand = applyTorFlags(command, false, implementation); } actions.updateNodeCommand({ diff --git a/src/utils/network.ts b/src/utils/network.ts index 1cdfc6aa68..7f42dbf0a0 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -790,23 +790,23 @@ export const getEffectiveCommand = (node: CommonNode): string => { let command = node.docker.command || getDefaultCommand(implementation, node.version); // Add Tor flags for LND nodes if enabled - if (node.type === 'lightning') { - const lnNode = node as LightningNode; - if (lnNode.implementation === 'LND' && lnNode.enableTor) { - command = applyTorFlags(command, !!lnNode.enableTor, lnNode.implementation); - } else if (lnNode.implementation === 'c-lightning' && lnNode.enableTor) { - command = applyTorFlags(command, !!lnNode.enableTor, lnNode.implementation); - } - } else if (node.type === 'bitcoin') { - const btcNode = node as BitcoinNode; - if (btcNode.implementation === 'bitcoind' && btcNode.enableTor) { - command = applyTorFlags(command, !!btcNode.enableTor, btcNode.implementation); + if (supportsTor(node)) { + const enableTor = node.enableTor; + if (enableTor) { + command = applyTorFlags(command, true, implementation); } } return command; }; +/** + * Check if a node implementation supports Tor + */ +export const supportsTor = (node: CommonNode): boolean => { + return node.type !== 'tap'; +}; + /** * Returns the images needed to start a network that are not included in the list * of images already pulled From aef29226a5a5a3f3f1f30b9c3fde69fe5b92613e Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sun, 4 Jan 2026 17:57:16 +0300 Subject: [PATCH 08/12] feat(tor-support): add Tor support for eclair nodes Extends existing Tor functionality to eclair lightning nodes. --- docker/eclair/Dockerfile | 6 ++-- docker/eclair/docker-entrypoint.sh | 45 +++++++++++++++++++++++++++--- src/lib/docker/composeFile.spec.ts | 17 ++++++++++- src/lib/docker/composeFile.ts | 13 +++++++-- src/utils/network.spec.ts | 2 +- src/utils/network.ts | 7 +++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/docker/eclair/Dockerfile b/docker/eclair/Dockerfile index 3316e1a0ba..d35e70af5f 100644 --- a/docker/eclair/Dockerfile +++ b/docker/eclair/Dockerfile @@ -59,7 +59,7 @@ FROM eclipse-temurin:25.0.1_8-jre-alpine WORKDIR /app # install jq for eclair-cli -RUN apk add bash jq curl unzip su-exec +RUN apk add bash jq curl unzip su-exec tor # copy and install eclair-cli executable COPY --from=BUILD /usr/src/eclair-core/eclair-cli . @@ -81,7 +81,7 @@ RUN chmod -R a+x eclair-node/* RUN ls -al eclair-node/bin RUN curl -SLO https://raw.githubusercontent.com/ACINQ/eclair/master/contrib/eclair-cli.bash-completion \ - && mkdir /etc/bash_completion.d \ + && mkdir -p /etc/bash_completion.d \ && mv eclair-cli.bash-completion /etc/bash_completion.d/ \ && curl -SLO https://raw.githubusercontent.com/scop/bash-completion/master/bash_completion \ && mkdir /usr/share/bash-completion/ \ @@ -94,7 +94,7 @@ RUN chmod a+x /entrypoint.sh VOLUME ["/home/eclair"] -EXPOSE 9735 8080 +EXPOSE 9735 8080 9050 9051 ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/eclair/docker-entrypoint.sh b/docker/eclair/docker-entrypoint.sh index 08987e1c81..9c8ecfef73 100644 --- a/docker/eclair/docker-entrypoint.sh +++ b/docker/eclair/docker-entrypoint.sh @@ -19,16 +19,53 @@ if ! id eclair > /dev/null 2>&1; then chown -R $USERID:$GROUPID /home/eclair fi + +if [ "${ENABLE_TOR}" = "true" ]; then + if getent group tor > /dev/null; then + addgroup eclair tor + fi + + echo "Starting Tor service..." + mkdir -p /var/lib/tor/eclair-service + if getent passwd tor > /dev/null; then + chown -R tor:tor /var/lib/tor + fi + chmod 700 /var/lib/tor + chmod 700 /var/lib/tor/eclair-service + + # Generate torrc file + cat > /etc/tor/torrc < { const clnNode = network.nodes.lightning[1] as CLightningNode; const litdNode = network.nodes.lightning[3] as LitdNode; const tapNode = network.nodes.tap[0] as TapdNode; + const eclairNode = network.nodes.lightning[2] as EclairNode; beforeEach(() => { composeFile = new ComposeFile(1); @@ -248,4 +249,18 @@ describe('ComposeFile', () => { const service = composeFile.content.services['bob']; expect(service.environment?.ENABLE_TOR).toBe('false'); }); + + it('should set ENABLE_TOR to true when tor is enabled on eclair node', () => { + eclairNode.enableTor = true; + composeFile.addEclair(eclairNode, btcNode); + const service = composeFile.content.services[eclairNode.name]; + expect(service.environment?.ENABLE_TOR).toBe('true'); + }); + + it('should set ENABLE_TOR to false when tor is disabled on eclair node', () => { + eclairNode.enableTor = false; + composeFile.addEclair(eclairNode, btcNode); + const service = composeFile.content.services[eclairNode.name]; + expect(service.environment?.ENABLE_TOR).toBe('false'); + }); }); diff --git a/src/lib/docker/composeFile.ts b/src/lib/docker/composeFile.ts index d50288bc4e..a3315f7dc8 100644 --- a/src/lib/docker/composeFile.ts +++ b/src/lib/docker/composeFile.ts @@ -169,11 +169,18 @@ class ComposeFile { // use the node's custom image or the default for the implementation const image = node.docker.image || `${dockerConfigs.eclair.imageName}:${version}`; // use the node's custom command or the default for the implementation - const nodeCommand = node.docker.command || getDefaultCommand('eclair', version); + let nodeCommand = node.docker.command || getDefaultCommand('eclair', version); // replace the variables in the command - const command = this.mergeCommand(nodeCommand, variables); + nodeCommand = this.mergeCommand(nodeCommand, variables); + // Apply Tor flags if Tor is enabled + nodeCommand = applyTorFlags(nodeCommand, !!node.enableTor, 'eclair'); // add the docker service - const svc = eclair(name, container, image, rest, p2p, command); + const svc = eclair(name, container, image, rest, p2p, nodeCommand); + // add ENABLE_TOR variable + svc.environment = { + ...svc.environment, + ENABLE_TOR: node.enableTor ? 'true' : 'false', + }; this.addService(svc); } diff --git a/src/utils/network.spec.ts b/src/utils/network.spec.ts index 670650849a..a79e112674 100644 --- a/src/utils/network.spec.ts +++ b/src/utils/network.spec.ts @@ -717,7 +717,7 @@ describe('Network Utils', () => { it('should return original command if implementation has no tor flags', () => { const command = '--foo=bar'; - expect(applyTorFlags(command, true, 'eclair' as NodeImplementation)).toBe(command); + expect(applyTorFlags(command, true, 'tapd' as NodeImplementation)).toBe(command); }); }); diff --git a/src/utils/network.ts b/src/utils/network.ts index 7f42dbf0a0..e5e772ac96 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -710,6 +710,13 @@ const getTorFlags = (implementation: NodeImplementation): string[] => { '--proxy=127.0.0.1:9050', '--always-use-proxy=true', ]; + case 'eclair': + return [ + '--tor.enabled=true', + '--tor.auth=safecookie', + '--socks5.enabled=true', + '--socks5.proxy=127.0.0.1:9050', + ]; case 'bitcoind': return [ '-proxy=127.0.0.1:9050', From 832d08dd2c091cfb86fa740030fcf42bdf8294d3 Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Sat, 10 Jan 2026 17:07:26 +0300 Subject: [PATCH 09/12] feat(tor-support): add Tor support for terminal nodes Extends existing Tor functionality to terminal lightning nodes. --- docker/litd/Dockerfile | 4 +-- docker/litd/docker-entrypoint.sh | 35 +++++++++++++++++++ src/lib/docker/composeFile.spec.ts | 14 ++++++++ src/lib/docker/composeFile.ts | 13 +++++-- .../clightning/clightningService.spec.ts | 29 +++++++++++++++ src/utils/network.ts | 17 +++++++++ 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/docker/litd/Dockerfile b/docker/litd/Dockerfile index 964f03a062..f0d286c57f 100644 --- a/docker/litd/Dockerfile +++ b/docker/litd/Dockerfile @@ -4,7 +4,7 @@ ARG LITD_VERSION ENV PATH=/opt/litd:$PATH RUN apt-get update -y \ - && apt-get install -y curl gosu wait-for-it \ + && apt-get install -y curl gosu wait-for-it tor\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -21,7 +21,7 @@ RUN chmod a+x /entrypoint.sh VOLUME ["/home/litd/.litd"] -EXPOSE 9735 8080 10000 +EXPOSE 9735 8080 10000 9050 9051 ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/litd/docker-entrypoint.sh b/docker/litd/docker-entrypoint.sh index 39999faa65..34ef0f8baf 100644 --- a/docker/litd/docker-entrypoint.sh +++ b/docker/litd/docker-entrypoint.sh @@ -14,6 +14,41 @@ if ! id litd > /dev/null 2>&1; then chown -R $USERID:$GROUPID /home/litd fi + +if [ "${ENABLE_TOR}" = "true" ]; then + if getent group debian-tor > /dev/null 2>&1; then + usermod -a -G debian-tor litd + fi + + echo "Starting Tor service..." + mkdir -p /var/lib/tor/litd-service + chown -R debian-tor:debian-tor /var/lib/tor + chmod 700 /var/lib/tor + chmod 700 /var/lib/tor/litd-service + + # Generate torrc file + cat > /etc/tor/torrc < { const service = composeFile.content.services[eclairNode.name]; expect(service.environment?.ENABLE_TOR).toBe('false'); }); + + it('should set ENABLE_TOR to true when tor is enabled on litd node', () => { + litdNode.enableTor = true; + composeFile.addLitd(litdNode, btcNode, litdNode); + const service = composeFile.content.services[litdNode.name]; + expect(service.environment?.ENABLE_TOR).toBe('true'); + }); + + it('should set ENABLE_TOR to false when tor is disabled on litd node', () => { + litdNode.enableTor = false; + composeFile.addLitd(litdNode, btcNode, litdNode); + const service = composeFile.content.services[litdNode.name]; + expect(service.environment?.ENABLE_TOR).toBe('false'); + }); }); diff --git a/src/lib/docker/composeFile.ts b/src/lib/docker/composeFile.ts index a3315f7dc8..c53c547d68 100644 --- a/src/lib/docker/composeFile.ts +++ b/src/lib/docker/composeFile.ts @@ -201,11 +201,18 @@ class ComposeFile { // use the node's custom image or the default for the implementation const image = node.docker.image || `${dockerConfigs.litd.imageName}:${version}`; // use the node's custom command or the default for the implementation - const nodeCommand = node.docker.command || getDefaultCommand('litd', version); + let nodeCommand = node.docker.command || getDefaultCommand('litd', version); // replace the variables in the command - const command = this.mergeCommand(nodeCommand, variables); + nodeCommand = this.mergeCommand(nodeCommand, variables); + // Apply Tor flags if Tor is enabled + nodeCommand = applyTorFlags(nodeCommand, !!node.enableTor, 'litd'); // add the docker service - const svc = litd(name, container, image, rest, grpc, p2p, web, command); + const svc = litd(name, container, image, rest, grpc, p2p, web, nodeCommand); + // add ENABLE_TOR variable + svc.environment = { + ...svc.environment, + ENABLE_TOR: node.enableTor ? 'true' : 'false', + }; this.addService(svc); } diff --git a/src/lib/lightning/clightning/clightningService.spec.ts b/src/lib/lightning/clightning/clightningService.spec.ts index 9154b0f534..a8d5b5a549 100644 --- a/src/lib/lightning/clightning/clightningService.spec.ts +++ b/src/lib/lightning/clightning/clightningService.spec.ts @@ -463,4 +463,33 @@ describe('CLightningService', () => { expect(clightningApiMock.httpPost).toHaveBeenCalledTimes(1); }); }); + + it('should get node info using torv3 address for rpcUrl', async () => { + const infoResponse: Partial = { + id: 'asdf', + alias: '', + address: [ + { + type: 'torv3', + address: 'toraddress1234567890.onion', + port: 9735, + }, + ], + binding: [{ type: 'ipv4', address: '0.0.0.0', port: 9735 }], + blockheight: 0, + numActiveChannels: 0, + numPendingChannels: 0, + numInactiveChannels: 0, + warningLightningdSync: 'blah', + }; + + clightningApiMock.httpPost.mockResolvedValue(infoResponse); + const expected = defaultStateInfo({ + pubkey: 'asdf', + rpcUrl: 'asdf@toraddress1234567890.onion:9735', + }); + + const actual = await clightningService.getInfo(node); + expect(actual).toEqual(expected); + }); }); diff --git a/src/utils/network.ts b/src/utils/network.ts index e5e772ac96..4da4cb8cad 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -717,6 +717,13 @@ const getTorFlags = (implementation: NodeImplementation): string[] => { '--socks5.enabled=true', '--socks5.proxy=127.0.0.1:9050', ]; + case 'litd': + return [ + '--lnd.tor.active', + '--lnd.tor.socks=127.0.0.1:9050', + '--lnd.tor.control=127.0.0.1:9051', + '--lnd.tor.v3', + ]; case 'bitcoind': return [ '-proxy=127.0.0.1:9050', @@ -757,6 +764,16 @@ export const applyTorFlags = ( }); } + if (implementation === 'litd' && enableTor) { + lines = lines.filter(line => { + const trimmed = line.trim(); + return !( + trimmed.startsWith('--lnd.listen=0.0.0.0') || + trimmed.startsWith('--lnd.externalip=') + ); + }); + } + if (implementation === 'c-lightning' && enableTor) { lines = lines.filter(line => { const trimmed = line.trim(); From 035ece64007048d897629938f2b531c7fc4220ce Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Tue, 20 Jan 2026 14:20:49 +0300 Subject: [PATCH 10/12] feat(tor-support): inherit Tor state for new nodes and disable network toggle when started - New nodes inherit network's Tor setting when added via drag-and-drop - Network-wide Tor toggle disabled when network is running --- src/components/network/NetworkActions.tsx | 9 ++++++++- src/i18n/locales/en-US.json | 1 + src/store/models/network.spec.ts | 9 +++++++++ src/store/models/network.ts | 8 ++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index a1f0fdb2a8..e538629cf2 100644 --- a/src/components/network/NetworkActions.tsx +++ b/src/components/network/NetworkActions.tsx @@ -82,13 +82,20 @@ const NetworkActions: React.FC = ({ ); const TorMenuSwitch = () => { + const isNetworkStarted = network.status === Status.Started; + return ( <> {hasTorSupportedNodes && ( - + {l('torTitle')} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 02bceae351..377f7ddf16 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -526,6 +526,7 @@ "cmps.network.NetworkActions.mineError": "Unable to mine blocks", "cmps.network.NetworkActions.torTitle": "Tor", "cmps.network.NetworkActions.torToggleTooltip": "Enable/Disable Tor for all supported nodes", + "cmps.network.NetworkActions.torToggleDisabledStarted": "Stop the network to change network-wide Tor settings", "cmps.network.NetworkActions.torEnabledAll": "Tor enabled for all supported nodes", "cmps.network.NetworkActions.torDisabledAll": "Tor disabled for all nodes", "cmps.network.NetworkActions.torToggleError": "Failed to toggle Tor settings", diff --git a/src/store/models/network.spec.ts b/src/store/models/network.spec.ts index f1ef750cc5..b897637073 100644 --- a/src/store/models/network.spec.ts +++ b/src/store/models/network.spec.ts @@ -375,6 +375,15 @@ describe('Network model', () => { const { lightning } = firstNetwork().nodes; expect(lightning[5].docker.command).toBe('test-command'); }); + + it('should inherit Tor setting when adding an LND node to a Tor-enabled network', async () => { + store.getActions().network.setAllNodesTor({ networkId: 1, enabled: true }); + const payload = { id: firstNetwork().id, type: 'LND', version: lndLatest }; + const newNode = await store.getActions().network.addNode(payload); + expect(newNode.enableTor).toBe(true); + const { lightning } = firstNetwork().nodes; + expect(lightning[lightning.length - 1].enableTor).toBe(true); + }); }); describe('Removing a Node', () => { diff --git a/src/store/models/network.ts b/src/store/models/network.ts index f2dd383b49..3ef413750e 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -387,6 +387,10 @@ const networkModel: NetworkModel = { const networks = getState().networks; const network = networks.find(n => n.id === id); if (!network) throw new Error(l('networkByIdErr', { networkId: id })); + + const networkHasTor = [...network.nodes.lightning, ...network.nodes.bitcoin].some( + n => supportsTor(n) && n.enableTor, + ); let node: AnyNode; // lookup custom image and startup command const docker = { image: '', command: '' }; @@ -469,6 +473,10 @@ const networkModel: NetworkModel = { default: throw new Error(`Cannot add unknown node type '${type}' to the network`); } + + if (supportsTor(node) && networkHasTor) { + node.enableTor = true; + } actions.setNetworks([...networks]); await actions.save(); await injections.dockerService.saveComposeFile(network); From 847d81d683ce3de918281b48d4247e52a52e097b Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Fri, 23 Jan 2026 14:58:41 +0300 Subject: [PATCH 11/12] feat(tor-support): add bitcoin network info and P2P host display - Adds getNetworkInfo() method to retrieve network details from bitcoind nodes, including p2pHost detection for both clearnet and Tor addresses. Formats long Tor v3 addresses using ellipsis for better UI display. Updates store models and UI components to display connection details. Updates connectPeers to use onion addresses --- .../designer/bitcoin/ConnectTab.tsx | 10 ++- .../bitcoin/bitcoind/bitcoindService.spec.ts | 59 ++++++++++++-- src/lib/bitcoin/bitcoind/bitcoindService.ts | 43 +++++++++- src/lib/bitcoin/notImplementedService.spec.ts | 3 +- src/lib/bitcoin/notImplementedService.ts | 7 +- src/store/models/bitcoin.spec.ts | 79 +++++++++++++++++++ src/store/models/bitcoin.ts | 60 ++++++++++++-- src/store/models/network.spec.ts | 36 +++++++++ src/store/models/network.ts | 10 ++- src/types/bitcoin-core.d.ts | 1 + src/types/index.ts | 5 +- src/utils/strings.spec.ts | 30 ++++++- src/utils/strings.ts | 25 ++++++ src/utils/tests/renderWithProviders.tsx | 1 + 14 files changed, 340 insertions(+), 29 deletions(-) create mode 100644 src/store/models/bitcoin.spec.ts diff --git a/src/components/designer/bitcoin/ConnectTab.tsx b/src/components/designer/bitcoin/ConnectTab.tsx index ec155925ce..6c696e71e9 100644 --- a/src/components/designer/bitcoin/ConnectTab.tsx +++ b/src/components/designer/bitcoin/ConnectTab.tsx @@ -4,8 +4,10 @@ import styled from '@emotion/styled'; import { Tooltip } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { BitcoinNode, Status } from 'shared/types'; -import { useStoreActions } from 'store'; +import { useStoreActions, useStoreState } from 'store'; import { bitcoinCredentials } from 'utils/constants'; +import { getNetworkBackendId } from 'utils/network'; +import { formatP2PHost } from 'utils/strings'; import { CopyIcon, DetailsList } from 'components/common'; import { DetailValues } from 'components/common/DetailsList'; @@ -30,21 +32,23 @@ interface Props { const ConnectTab: React.FC = ({ node }) => { const { l } = usePrefixedTranslation('cmps.designer.bitcoind.ConnectTab'); const { openInBrowser } = useStoreActions(s => s.app); + const nodeState = useStoreState(s => s.bitcoin.nodes[getNetworkBackendId(node)]); if (node.status !== Status.Started) { return <>{l('notStarted')}; } + const p2pHost = nodeState?.info?.p2pHost; const details: DetailValues = [ { label: l('rpcHost'), value: `http://127.0.0.1:${node.ports.rpc}` }, - { label: l('p2pHost'), value: `tcp://127.0.0.1:${node.ports.p2p}` }, + { label: l('p2pHost'), value: p2pHost || `tcp://127.0.0.1:${node.ports.p2p}` }, { label: l('zmqBlockHost'), value: `tcp://127.0.0.1:${node.ports.zmqBlock}` }, { label: l('zmqTxHost'), value: `tcp://127.0.0.1:${node.ports.zmqTx}` }, { label: l('rpcUser'), value: bitcoinCredentials.user }, { label: l('rpcPass'), value: bitcoinCredentials.pass }, ].map(({ label, value }) => ({ label, - value: , + value: , })); const restDocsUrl = diff --git a/src/lib/bitcoin/bitcoind/bitcoindService.spec.ts b/src/lib/bitcoin/bitcoind/bitcoindService.spec.ts index b02aa40860..6e212617c2 100644 --- a/src/lib/bitcoin/bitcoind/bitcoindService.spec.ts +++ b/src/lib/bitcoin/bitcoind/bitcoindService.spec.ts @@ -30,6 +30,27 @@ describe('BitcoindService', () => { mockProto.getNewAddress = jest.fn().mockResolvedValue('abcdef'); mockProto.sendToAddress = jest.fn().mockResolvedValue('txid'); mockProto.generateToAddress = jest.fn().mockResolvedValue(['blockhash1']); + mockProto.getNetworkInfo = jest.fn().mockResolvedValue({ + version: 290000, + subversion: '/Satoshi:29.0.0/', + protocolversion: 70016, + localservices: '0000000000000c49', + localrelay: true, + timeoffset: 0, + connections: 0, + networkactive: true, + networks: [], + relayfee: 0.00001, + incrementalfee: 0.00001, + localaddresses: [ + { + address: 'test123.onion', + port: 18444, + score: 4, + }, + ], + warnings: [], + }); }); it('should create a default wallet', async () => { @@ -51,10 +72,10 @@ describe('BitcoindService', () => { expect(info.blocks).toEqual(10); }); - it('should get wallet info', async () => { - const info = await bitcoindService.getWalletInfo(node); - expect(mockBitcoin.mock.instances[0].getWalletInfo).toBeCalledTimes(1); - expect(info.balance).toEqual(5); + it('should get network info', async () => { + const info = await bitcoindService.getNetworkInfo(node); + expect(getInst().getNetworkInfo).toBeCalledTimes(1); + expect(info.incrementalfee).toEqual(0.00001); }); it('should get new address', async () => { @@ -64,14 +85,16 @@ describe('BitcoindService', () => { }); it('should connect peers', async () => { - await bitcoindService.connectPeers(node); + await bitcoindService.connectPeers(node, ['backend-1']); expect(mockBitcoin.mock.instances[0].addNode).toBeCalledTimes(1); }); it('should not throw error if connect peers fails', async () => { mockProto.addNode = jest.fn().mockRejectedValue('add-error'); - await bitcoindService.connectPeers(node); - await expect(bitcoindService.connectPeers(node)).resolves.not.toThrow(); + await bitcoindService.connectPeers(node, ['backend-1']); + await expect( + bitcoindService.connectPeers(node, ['backend-1']), + ).resolves.not.toThrow(); }); it('should mine new blocks', async () => { @@ -130,6 +153,28 @@ describe('BitcoindService', () => { }); }); + it('should get wallet info', async () => { + const info = await bitcoindService.getWalletInfo(node); + expect(mockBitcoin.mock.instances[0].getWalletInfo).toBeCalledTimes(1); + expect(info.balance).toEqual(5); + }); + + it('should get network info with onion address', async () => { + const info = await bitcoindService.getNetworkInfo(node); + expect(getInst().getNetworkInfo).toBeCalledTimes(1); + expect(info.p2pHost).toEqual('test123.onion:18444'); + }); + + it('should get network info without onion address', async () => { + mockProto.getNetworkInfo = jest.fn().mockResolvedValue({ + version: 290000, + incrementalfee: 0.00001, + localaddresses: [], + }); + const info = await bitcoindService.getNetworkInfo(node); + expect(info.p2pHost).toEqual(`tcp://127.0.0.1:${node.ports.p2p}`); + }); + describe('sendFunds', () => { it('should send funds with sufficient balance', async () => { const txid = await bitcoindService.sendFunds(node, 'destaddr', 1); diff --git a/src/lib/bitcoin/bitcoind/bitcoindService.ts b/src/lib/bitcoin/bitcoind/bitcoindService.ts index 00ff128308..b6c56c9272 100644 --- a/src/lib/bitcoin/bitcoind/bitcoindService.ts +++ b/src/lib/bitcoin/bitcoind/bitcoindService.ts @@ -35,6 +35,38 @@ class BitcoindService implements BitcoinService { return await this.createClient(node).getBlockchainInfo(); } + async getNetworkInfo(node: BitcoinNode) { + const info = await this.createClient(node).getNetworkInfo(); + + let p2pHost = ''; + const onionAddr = info.localaddresses?.find((addr: any) => + addr.address.endsWith('.onion'), + ); + if (onionAddr) { + p2pHost = `${onionAddr.address}:${onionAddr.port}`; + } + if (!p2pHost) { + p2pHost = `tcp://127.0.0.1:${node.ports.p2p}`; + } + + return { + version: info.version, + subversion: info.subversion, + protocolversion: info.protocolversion, + localservices: info.localservices, + localrelay: info.localrelay, + timeoffset: info.timeoffset, + connections: info.connections, + networkactive: info.networkactive, + networks: info.networks, + relayfee: info.relayfee, + incrementalfee: info.incrementalfee, + localaddresses: info.localaddresses, + warnings: info.warnings, + p2pHost, + }; + } + async getWalletInfo(node: BitcoinNode): Promise { const client = this.createClient(node); const walletInfo = await client.getWalletInfo(); @@ -52,13 +84,16 @@ class BitcoindService implements BitcoinService { return await this.createClient(node).getNewAddress(); } - async connectPeers(node: BitcoinNode) { + async connectPeers(node: BitcoinNode, peerAddresses: string[]) { const client = this.createClient(node); - for (const peer of node.peers) { + for (const peerAddr of peerAddresses) { try { - await client.addNode(peer, 'add'); + await client.addNode(peerAddr, 'add'); } catch (error: any) { - logger.debug(`Failed to add peer '${peer}' to bitcoind node ${node.name}`, error); + logger.debug( + `Failed to add peer '${peerAddr}' to bitcoind node ${node.name}`, + error, + ); } } } diff --git a/src/lib/bitcoin/notImplementedService.spec.ts b/src/lib/bitcoin/notImplementedService.spec.ts index 2a5e5af031..390b1f7e54 100644 --- a/src/lib/bitcoin/notImplementedService.spec.ts +++ b/src/lib/bitcoin/notImplementedService.spec.ts @@ -10,9 +10,10 @@ describe('NotImplementedService', () => { expect(() => service.waitUntilOnline(node)).toThrow(msg('waitUntilOnline')); expect(() => service.createDefaultWallet(node)).toThrow(msg('createDefaultWallet')); expect(() => service.getBlockchainInfo(node)).toThrow(msg('getBlockchainInfo')); + expect(() => service.getNetworkInfo(node)).toThrow(msg('getNetworkInfo')); expect(() => service.getWalletInfo(node)).toThrow(msg('getWalletInfo')); expect(() => service.getNewAddress(node)).toThrow(msg('getNewAddress')); - expect(() => service.connectPeers(node)).toThrow(msg('connectPeers')); + expect(() => service.connectPeers(node, [''])).toThrow(msg('connectPeers')); expect(() => service.mine(1, node)).toThrow(msg('mine')); expect(() => service.sendFunds(node, '', 0)).toThrow(msg('sendFunds')); }); diff --git a/src/lib/bitcoin/notImplementedService.ts b/src/lib/bitcoin/notImplementedService.ts index be326173db..c72e5c7156 100644 --- a/src/lib/bitcoin/notImplementedService.ts +++ b/src/lib/bitcoin/notImplementedService.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { BitcoinNode } from 'shared/types'; import { BitcoinService } from 'types'; -import { ChainInfo, WalletInfoCompat } from 'types/bitcoin-core'; +import { ChainInfo, NetworkInfo, WalletInfoCompat } from 'types/bitcoin-core'; /** * A Bitcoin Service class whose functions are not yet implemented @@ -22,13 +22,16 @@ class NotImplementedService implements BitcoinService { `getBlockchainInfo is not implemented for ${node.implementation} nodes`, ); } + getNetworkInfo(node: BitcoinNode): Promise { + throw new Error(`getNetworkInfo is not implemented for ${node.implementation} nodes`); + } getWalletInfo(node: BitcoinNode): Promise { throw new Error(`getWalletInfo is not implemented for ${node.implementation} nodes`); } getNewAddress(node: BitcoinNode): Promise { throw new Error(`getNewAddress is not implemented for ${node.implementation} nodes`); } - connectPeers(node: BitcoinNode): Promise { + connectPeers(node: BitcoinNode, peerAddresses: string[]): Promise { throw new Error(`connectPeers is not implemented for ${node.implementation} nodes`); } mine(numBlocks: number, node: BitcoinNode): Promise { diff --git a/src/store/models/bitcoin.spec.ts b/src/store/models/bitcoin.spec.ts new file mode 100644 index 0000000000..c0a09d856f --- /dev/null +++ b/src/store/models/bitcoin.spec.ts @@ -0,0 +1,79 @@ +import { createStore } from 'easy-peasy'; +import { createBitcoindNetworkNode } from 'utils/network'; +import { bitcoinServiceMock, getNetwork, injections, testNodeDocker } from 'utils/tests'; +import appModel from './app'; +import bitcoinModel from './bitcoin'; +import designerModel from './designer'; +import lightningModel from './lightning'; +import modalsModel from './modals'; +import networkModel from './network'; + +describe('Bitcoin Model', () => { + const rootModel = { + app: appModel, + network: networkModel, + bitcoin: bitcoinModel, + lightning: lightningModel, + designer: designerModel, + modals: modalsModel, + }; + const network = getNetwork(); + + let store = createStore(rootModel, { injections }); + const node = network.nodes.bitcoin[0]; + let peerNode: any; + + beforeEach(() => { + store = createStore(rootModel, { + injections, + initialState: { + network: { + networks: [network], + }, + }, + }); + peerNode = createBitcoindNetworkNode(network, '0.18.1', testNodeDocker); + peerNode.name = 'peer-backend1'; + network.nodes.bitcoin.push(peerNode); + + node.peers = ['peer-backend1']; + + bitcoinServiceMock.getBlockchainInfo.mockResolvedValue({ blocks: 100 } as any); + bitcoinServiceMock.getWalletInfo.mockResolvedValue({ balance: 5 } as any); + bitcoinServiceMock.getNetworkInfo.mockResolvedValue({ + p2pHost: 'test123.onion:18444', + } as any); + bitcoinServiceMock.connectPeers.mockResolvedValue(undefined); + }); + + it('should connect using onion address when both nodes have Tor enabled', async () => { + node.enableTor = true; + peerNode.enableTor = true; + await store.getActions().bitcoin.connectAllPeers(network); + expect(bitcoinServiceMock.connectPeers).toHaveBeenCalledWith(node, [ + 'test123.onion:18444', + ]); + }); + + it('should connect using peer name when both nodes have Tor disabled', async () => { + node.enableTor = false; + peerNode.enableTor = false; + await store.getActions().bitcoin.connectAllPeers(network); + expect(bitcoinServiceMock.connectPeers).toHaveBeenCalledWith(node, ['peer-backend1']); + }); + + it('should connect using peer name when peer node is not found in network', async () => { + node.enableTor = true; + node.peers = ['non-existent-peer']; + await store.getActions().bitcoin.connectAllPeers(network); + expect(bitcoinServiceMock.connectPeers).toHaveBeenCalledWith(node, [ + 'non-existent-peer', + ]); + }); + + it('should not throw an error when connecting peers', async () => { + const { connectAllPeers } = store.getActions().bitcoin; + bitcoinServiceMock.getNetworkInfo.mockRejectedValueOnce(new Error('getInfo-error')); + await expect(connectAllPeers(network)).resolves.not.toThrow(); + }); +}); diff --git a/src/store/models/bitcoin.ts b/src/store/models/bitcoin.ts index 6df50f30e7..2083f2ffee 100644 --- a/src/store/models/bitcoin.ts +++ b/src/store/models/bitcoin.ts @@ -1,7 +1,7 @@ import { Action, action, Thunk, thunk } from 'easy-peasy'; import { BitcoinNode, Status } from 'shared/types'; -import { StoreInjections } from 'types'; -import { ChainInfo, WalletInfoCompat } from 'types/bitcoin-core'; +import { Network, StoreInjections } from 'types'; +import { ChainInfo, NetworkInfo, WalletInfoCompat } from 'types/bitcoin-core'; import { delay } from 'utils/async'; import { getNetworkBackendId } from 'utils/network'; import { prefixTranslation } from 'utils/translate'; @@ -17,6 +17,7 @@ export interface BitcoinNodeMapping { export interface BitcoinNodeModel { chainInfo?: ChainInfo; walletInfo?: WalletInfoCompat; + info?: NetworkInfo; } export interface BitcoinModel { @@ -25,7 +26,12 @@ export interface BitcoinModel { clearNodes: Action; setInfo: Action< BitcoinModel, - { node: BitcoinNode; chainInfo: ChainInfo; walletInfo: WalletInfoCompat } + { + node: BitcoinNode; + chainInfo: ChainInfo; + walletInfo: WalletInfoCompat; + networkInfo: NetworkInfo; + } >; getInfo: Thunk; mine: Thunk< @@ -41,6 +47,7 @@ export interface BitcoinModel { RootModel, Promise >; + connectAllPeers: Thunk; } const bitcoinModel: BitcoinModel = { @@ -52,11 +59,12 @@ const bitcoinModel: BitcoinModel = { clearNodes: action(state => { state.nodes = {}; }), - setInfo: action((state, { node, chainInfo, walletInfo }) => { + setInfo: action((state, { node, chainInfo, walletInfo, networkInfo }) => { const id = getNetworkBackendId(node); if (!state.nodes[id]) state.nodes[id] = {}; state.nodes[id].chainInfo = chainInfo; state.nodes[id].walletInfo = walletInfo; + state.nodes[id].info = networkInfo; }), getInfo: thunk(async (actions, node, { injections }) => { const chainInfo = await injections.bitcoinFactory @@ -65,7 +73,10 @@ const bitcoinModel: BitcoinModel = { const walletInfo = await injections.bitcoinFactory .getService(node) .getWalletInfo(node); - actions.setInfo({ node, chainInfo, walletInfo }); + const networkInfo = await injections.bitcoinFactory + .getService(node) + .getNetworkInfo(node); + actions.setInfo({ node, chainInfo, walletInfo, networkInfo }); }), mine: thunk(async (actions, { blocks, node }, { injections, getStoreState }) => { if (blocks < 0) throw new Error(l('mineError')); @@ -88,6 +99,45 @@ const bitcoinModel: BitcoinModel = { return txid; }, ), + connectAllPeers: thunk( + async (actions, network, { injections, getState, getStoreActions }) => { + const { notify } = getStoreActions().app; + + for (const node of network.nodes.bitcoin) { + try { + await actions.getInfo(node); + } catch (error: any) { + notify({ + message: `Failed to get info for bitcoin node ${node.name}`, + error, + }); + } + } + + const { nodes } = getState(); + for (const node of network.nodes.bitcoin) { + const peerAddresses: string[] = []; + + for (const peerName of node.peers) { + const peerNode = network.nodes.bitcoin.find(n => n.name === peerName); + + if (node.enableTor && peerNode?.enableTor) { + const peerNodeId = getNetworkBackendId(peerNode); + const p2pHost = nodes[peerNodeId]?.info?.p2pHost; + if (p2pHost) { + peerAddresses.push(p2pHost); + } + } else { + peerAddresses.push(peerName); + } + } + // connect each bitcoin node to it's peers so tx & block propagation is fast + await injections.bitcoinFactory + .getService(node) + .connectPeers(node, peerAddresses); + } + }, + ), }; export default bitcoinModel; diff --git a/src/store/models/network.spec.ts b/src/store/models/network.spec.ts index b897637073..33eee42429 100644 --- a/src/store/models/network.spec.ts +++ b/src/store/models/network.spec.ts @@ -1082,6 +1082,42 @@ describe('Network model', () => { await monitorStartup(bitcoin); expect(bitcoinServiceMock.waitUntilOnline).not.toHaveBeenCalled(); }); + + it('should use longer delay when bitcoin nodes have Tor enabled', async () => { + asyncUtilMock.delay.mockResolvedValue(0); + const { addNetwork, monitorStartup, setNodeTor } = store.getActions().network; + await addNetwork(addNetworkArgs); + const network = firstNetwork(); + setNodeTor({ + networkId: network.id, + nodeName: network.nodes.bitcoin[0].name, + enabled: true, + }); + + await monitorStartup(firstNetwork().nodes.bitcoin); + await waitFor(() => { + expect(bitcoinServiceMock.waitUntilOnline).toHaveBeenCalled(); + }); + expect(asyncUtilMock.delay).toHaveBeenCalledWith(4000); + }); + + it('should use longer delay when lightning nodes have Tor enabled', async () => { + asyncUtilMock.delay.mockResolvedValue(0); + const { addNetwork, monitorStartup, setNodeTor } = store.getActions().network; + await addNetwork(addNetworkArgs); + const network = firstNetwork(); + setNodeTor({ + networkId: network.id, + nodeName: network.nodes.lightning[0].name, + enabled: true, + }); + + await monitorStartup(firstNetwork().nodes.lightning); + await waitFor(() => { + expect(lightningServiceMock.waitUntilOnline).toHaveBeenCalled(); + }); + expect(asyncUtilMock.delay).toHaveBeenCalledWith(4000); + }); }); describe('ManualMineCount', () => { diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 3ef413750e..7d1245fc61 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -951,11 +951,8 @@ const networkModel: NetworkModel = { .waitUntilOnline(btc) .then(async () => { actions.setStatus({ id, status: Status.Started, only: btc.name }); - // connect each bitcoin node to it's peers so tx & block propagation is fast - await injections.bitcoinFactory.getService(btc).connectPeers(btc); // create a default wallet since it's not automatic on v0.21.0 and up await injections.bitcoinFactory.getService(btc).createDefaultWallet(btc); - await getStoreActions().bitcoin.getInfo(btc); }) .catch(error => actions.setStatus({ id, status: Status.Error, only: btc.name, error }), @@ -979,7 +976,10 @@ const networkModel: NetworkModel = { const node = network.nodes.bitcoin[0]; await Promise.all(btcNodesOnline) .then(async () => { - await delay(2000); + await getStoreActions().bitcoin.connectAllPeers(network); + const hasTor = network.nodes.bitcoin.some(n => n.enableTor); + // add a longer delay to allow nodes to connect to peers because tor connection is slower + await delay(hasTor ? 4000 : 2000); await getStoreActions().bitcoin.mine({ node, blocks: 1 }); }) .catch(e => info('Failed to mine a block after network startup', e)); @@ -990,6 +990,8 @@ const networkModel: NetworkModel = { await Promise.all(lnNodesOnline) .then(async () => { await getStoreActions().lightning.connectAllPeers(network); + const hasTor = network.nodes.lightning.some(n => n.enableTor); + await delay(hasTor ? 4000 : 2000); // Add listeners to lightning nodes await getStoreActions().lightning.addListeners(network); }) diff --git a/src/types/bitcoin-core.d.ts b/src/types/bitcoin-core.d.ts index f06b2022cc..511891604a 100644 --- a/src/types/bitcoin-core.d.ts +++ b/src/types/bitcoin-core.d.ts @@ -92,6 +92,7 @@ type NetworkInfo = { score: number; }[]; warnings?: string; + p2pHost?: string; }; type PeerInfo = { diff --git a/src/types/index.ts b/src/types/index.ts index af436c53b9..8d94596a15 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,7 +17,7 @@ import * as PLN from 'lib/lightning/types'; import * as PLIT from 'lib/litd/types'; import * as PTAP from 'lib/tap/types'; import { PolarPlatform } from 'utils/system'; -import { ChainInfo, WalletInfoCompat } from './bitcoin-core'; +import { ChainInfo, NetworkInfo, WalletInfoCompat } from './bitcoin-core'; export interface Network { id: number; @@ -154,9 +154,10 @@ export interface BitcoinService { waitUntilOnline: (node: BitcoinNode) => Promise; createDefaultWallet: (node: BitcoinNode) => Promise; getBlockchainInfo: (node: BitcoinNode) => Promise; + getNetworkInfo: (node: BitcoinNode) => Promise; getWalletInfo: (node: BitcoinNode) => Promise; getNewAddress: (node: BitcoinNode) => Promise; - connectPeers: (node: BitcoinNode) => Promise; + connectPeers: (node: BitcoinNode, peerAddresses: string[]) => Promise; mine: (numBlocks: number, node: BitcoinNode) => Promise; sendFunds: (node: BitcoinNode, addr: string, amount: number) => Promise; } diff --git a/src/utils/strings.spec.ts b/src/utils/strings.spec.ts index d62285f4be..a33e8de3e7 100644 --- a/src/utils/strings.spec.ts +++ b/src/utils/strings.spec.ts @@ -1,4 +1,9 @@ -import { compareVersions, ellipseInner, isVersionCompatible } from './strings'; +import { + compareVersions, + ellipseInner, + formatP2PHost, + isVersionCompatible, +} from './strings'; describe('strings util', () => { describe('ellipseInner', () => { @@ -96,4 +101,27 @@ describe('strings util', () => { expect(compareVersions('0.18.2-beta', '0.18.1-beta.rc1')).toBe(1); }); }); + + describe('formatP2PHost', () => { + it('should return clearnet hosts unchanged', () => { + expect(formatP2PHost('tcp://127.0.0.1:8333')).toEqual('tcp://127.0.0.1:8333'); + expect(formatP2PHost('192.168.1.10:18444')).toEqual('192.168.1.10:18444'); + }); + + it('should ellipse tor (.onion) hosts', () => { + const onion = 'abcdef1234567890abcdef1234567890abcdef1234567890.onion:8333'; + expect(formatP2PHost(onion)).toEqual(ellipseInner(onion, 3, 17)); + }); + + it('should do nothing with short tor hosts', () => { + const shortOnion = 'abcd.onion:8333'; + expect(formatP2PHost(shortOnion)).toEqual(shortOnion); + }); + + it('should handle empty or invalid input', () => { + expect(formatP2PHost(undefined)).toBeUndefined(); + expect(formatP2PHost(null as unknown as string)).toBeNull(); + expect(formatP2PHost('')).toEqual(''); + }); + }); }); diff --git a/src/utils/strings.ts b/src/utils/strings.ts index 1d257d77a6..b3163d04c3 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -25,6 +25,31 @@ export const ellipseInner = ( return `${firstChars}...${lastChars}`; }; +/** + * Formats a Bitcoin P2P host string for UI display. + * - Clearnet addresses are returned unchanged + * - Tor (.onion) addresses are shortened using inner ellipsis + * This ensures long Tor v3 onion addresses do not break layouts + * or hide copy controls while keeping UX consistent with LN hosts. + * @param host the p2p host string (clearnet or onion) + * @param leftChars number of characters to keep on the left side + * @param rightChars number of characters to keep on the right side + * + * @example + * formatP2PHost('tcp://127.0.0.1:19444'); + * // 'tcp://127.0.0.1:19444' + * + * formatP2PHost('3ulrxzdgwvib2m3ald6acuviisda6br6uj2i7ekendv3wgpdwsixatad.onion:18444'); + * // ' 3ul...xatad.onion:18444' + */ +export const formatP2PHost = (host?: string) => { + if (!host) return host; + if (host.endsWith('.onion') || host.includes('.onion:')) { + return ellipseInner(host, 3, 17); + } + return host; +}; + /** * Checks if the version provided is equal or lower than the maximum version provided. * @param version the version to compare diff --git a/src/utils/tests/renderWithProviders.tsx b/src/utils/tests/renderWithProviders.tsx index c80d092d31..2620d2c500 100644 --- a/src/utils/tests/renderWithProviders.tsx +++ b/src/utils/tests/renderWithProviders.tsx @@ -12,6 +12,7 @@ export const bitcoinServiceMock: jest.Mocked = { waitUntilOnline: jest.fn(), createDefaultWallet: jest.fn(), getBlockchainInfo: jest.fn(), + getNetworkInfo: jest.fn(), getWalletInfo: jest.fn(), getNewAddress: jest.fn(), connectPeers: jest.fn(), From a245860851d9e4556b7005d07f927991530bc18f Mon Sep 17 00:00:00 2001 From: Jemimah Nagasha Date: Wed, 18 Feb 2026 15:50:02 +0300 Subject: [PATCH 12/12] feat(tor-support): p2pExternal address & tor flag modifications --- .../designer/lightning/ConnectTab.tsx | 10 +++++---- src/utils/network.ts | 21 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/designer/lightning/ConnectTab.tsx b/src/components/designer/lightning/ConnectTab.tsx index c7125fc449..d0264962d2 100644 --- a/src/components/designer/lightning/ConnectTab.tsx +++ b/src/components/designer/lightning/ConnectTab.tsx @@ -86,6 +86,8 @@ const ConnectTab: React.FC = ({ node }) => { const nodeState = useStoreState(s => s.lightning.nodes[node.name]); const pubkey = nodeState && nodeState.info ? nodeState.info.pubkey : ''; const p2pLnUrlInternal = nodeState && nodeState.info ? nodeState.info.rpcUrl : ''; + const p2pUriExternal = (port: number) => + node.enableTor ? p2pLnUrlInternal : `${pubkey}@127.0.0.1:${port}`; const info = useMemo((): ConnectionInfo => { if (node.status === Status.Started) { @@ -102,7 +104,7 @@ const ConnectTab: React.FC = ({ node }) => { invoice: lnd.paths.invoiceMacaroon, cert: lnd.paths.tlsCert, }, - p2pUriExternal: `${pubkey}@127.0.0.1:${lnd.ports.p2p}`, + p2pUriExternal: p2pUriExternal(lnd.ports.p2p), authTypes: ['paths', 'hex', 'base64', 'lndc'], }; } else if (node.implementation === 'c-lightning') { @@ -118,7 +120,7 @@ const ConnectTab: React.FC = ({ node }) => { clientCert: cln.paths.tlsClientCert, clientKey: cln.paths.tlsClientKey, }, - p2pUriExternal: `${pubkey}@127.0.0.1:${cln.ports.p2p}`, + p2pUriExternal: p2pUriExternal(cln.ports.p2p), authTypes: ['paths', 'hex', 'base64'], }; } else if (node.implementation === 'eclair') { @@ -129,7 +131,7 @@ const ConnectTab: React.FC = ({ node }) => { credentials: { basicAuth: eclairCredentials.pass, }, - p2pUriExternal: `${pubkey}@127.0.0.1:${eln.ports.p2p}`, + p2pUriExternal: p2pUriExternal(eln.ports.p2p), authTypes: ['basic'], }; } else if (node.implementation === 'litd') { @@ -148,7 +150,7 @@ const ConnectTab: React.FC = ({ node }) => { lit: litd.paths.litMacaroon, tap: litd.paths.tapMacaroon, }, - p2pUriExternal: `${pubkey}@127.0.0.1:${litd.ports.p2p}`, + p2pUriExternal: p2pUriExternal(litd.ports.p2p), authTypes: ['paths', 'hex', 'base64', 'lnc'], }; } diff --git a/src/utils/network.ts b/src/utils/network.ts index 4da4cb8cad..0f8bb5d3d9 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -699,9 +699,11 @@ const getTorFlags = (implementation: NodeImplementation): string[] => { case 'LND': return [ '--tor.active', + '--tor.v3', + '--tor.streamisolation', + '--listen=localhost', '--tor.socks=127.0.0.1:9050', '--tor.control=127.0.0.1:9051', - '--tor.v3', ]; case 'c-lightning': return [ @@ -715,14 +717,17 @@ const getTorFlags = (implementation: NodeImplementation): string[] => { '--tor.enabled=true', '--tor.auth=safecookie', '--socks5.enabled=true', - '--socks5.proxy=127.0.0.1:9050', + '--socks5.use-for-tor=true', + '--server.address=127.0.0.1', ]; case 'litd': return [ '--lnd.tor.active', + '--lnd.tor.v3', + '--lnd.tor.streamisolation', + '--lnd.listen=localhost', '--lnd.tor.socks=127.0.0.1:9050', '--lnd.tor.control=127.0.0.1:9051', - '--lnd.tor.v3', ]; case 'bitcoind': return [ @@ -781,21 +786,17 @@ export const applyTorFlags = ( return !(trimmed.startsWith('--addr=') && !trimmed.includes('statictor')); }); } - let cleanCommand = lines.join('\n').trim(); - if (implementation === 'bitcoind') { - cleanCommand = cleanCommand.replace( - '-listenonion=0', - `-listenonion=${enableTor ? '1' : '0'}`, - ); + if (implementation === 'bitcoind' && enableTor) { + lines = lines.filter(line => line.trim() !== '-listenonion=0'); } + let cleanCommand = lines.join('\n').trim(); // Add Tor flags if enabled if (enableTor) { const torFlagsStr = torFlags.join('\n '); cleanCommand = `${cleanCommand}\n ${torFlagsStr}`; } - return cleanCommand; };