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 < /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 < /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 < /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 < /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/common/TorButton.spec.tsx b/src/components/common/TorButton.spec.tsx new file mode 100644 index 0000000000..4139c481b9 --- /dev/null +++ b/src/components/common/TorButton.spec.tsx @@ -0,0 +1,204 @@ +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(); + }); + + 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 new file mode 100644 index 0000000000..6b4aa4b188 --- /dev/null +++ b/src/components/common/TorButton.tsx @@ -0,0 +1,96 @@ +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'; +import { supportsTor } from 'utils/network'; + +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); + + 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`)} +
+ ); + } + + if (!supportsTor(node)) { + return ( + + + + ); + } + + 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..8c9693c09c 100644 --- a/src/components/designer/NodeContextMenu.tsx +++ b/src/components/designer/NodeContextMenu.tsx @@ -3,11 +3,13 @@ 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, - RestartNode, RenameNodeButton, + RestartNode, + TorButton, } from 'components/common'; import { ViewLogsButton } from 'components/dockerLogs'; import { OpenTerminalButton } from 'components/terminal'; @@ -47,6 +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 = (isLN || isBackend) && !!node.enableTor; + const isTorSupported = supportsTor(node); let items: MenuProps['items'] = []; items = items.concat( @@ -109,6 +113,16 @@ const NodeContextMenu: React.FC = ({ node: { id }, children }) => { ), addItemIf('rename', ), addItemIf('options', ), + addItemIf( + 'enable', + , + isTorSupported && !isTorEnabled, + ), + addItemIf( + 'disable', + , + isTorSupported && 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/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/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/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/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/components/network/NetworkActions.spec.tsx b/src/components/network/NetworkActions.spec.tsx index 5e498198a0..95b31cc48f 100644 --- a/src/components/network/NetworkActions.spec.tsx +++ b/src/components/network/NetworkActions.spec.tsx @@ -3,25 +3,35 @@ 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)); + network.nodes.bitcoin.forEach(n => { + n.status = status; + n.enableTor = enableTor; + }); const chart = initChartFromNetwork(network); + network.nodes.lightning.forEach(n => (n.enableTor = enableTor)); + const initialState = { network: { networks: [network], @@ -164,4 +174,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/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index 12410f7b63..e538629cf2 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 { getNetworkBackendId, supportsTor } 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,65 @@ const NetworkActions: React.FC = ({ const mineAsync = useMiningAsync(network); + 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 { + 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 isNetworkStarted = network.status === Status.Started; + + return ( + <> + {hasTorSupportedNodes && ( + + + {l('torTitle')} + + } + unCheckedChildren={ + <> + {l('torTitle')} + + } + /> + + )} + + ); + }; + const handleClick: MenuProps['onClick'] = useCallback((info: { key: string }) => { switch (info.key) { case 'rename': @@ -70,6 +131,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..377f7ddf16 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -71,6 +71,19 @@ "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.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", @@ -511,6 +524,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.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", "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/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/lib/docker/composeFile.spec.ts b/src/lib/docker/composeFile.spec.ts index c33d0af4d2..0f4a2591b8 100644 --- a/src/lib/docker/composeFile.spec.ts +++ b/src/lib/docker/composeFile.spec.ts @@ -1,4 +1,4 @@ -import { CLightningNode, LitdNode, LndNode, TapdNode } from 'shared/types'; +import { CLightningNode, EclairNode, LitdNode, LndNode, TapdNode } from 'shared/types'; import { bitcoinCredentials, defaultRepoState } from 'utils/constants'; import { createNetwork } from 'utils/network'; import { testManagedImages } from 'utils/tests'; @@ -26,6 +26,7 @@ describe('ComposeFile', () => { 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); @@ -205,4 +206,75 @@ 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'); + }); + + 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'); + }); + + 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 a1e8497068..c53c547d68 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; @@ -70,11 +70,21 @@ class ComposeFile { // use the node's custom image or the default for the implementation const image = node.docker.image || `${dockerConfigs.bitcoind.imageName}:${version}`; // use the node's custom command or the default for the implementation - const nodeCommand = node.docker.command || getDefaultCommand('bitcoind', version); + let nodeCommand = node.docker.command || getDefaultCommand('bitcoind', version); + + // Apply Tor flags if Tor is enabled + nodeCommand = applyTorFlags(nodeCommand, !!node.enableTor, 'bitcoind'); // replace the variables in the command const command = this.mergeCommand(nodeCommand, variables); + // add the docker service const svc = bitcoind(name, container, image, rpc, p2p, zmqBlock, zmqTx, command); + // add ENABLE_TOR variable + svc.environment = { + ...svc.environment, + ENABLE_TOR: node.enableTor ? 'true' : 'false', + }; + this.addService(svc); } @@ -93,11 +103,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, 'LND'); + // 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); } @@ -120,9 +140,17 @@ class ComposeFile { // do not include the GRPC port arg in the command for unsupported versions if (grpc === 0) nodeCommand = nodeCommand.replace('--grpc-port=11001', ''); // 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, 'c-lightning'); // add the docker service - const svc = clightning(name, container, image, rest, grpc, p2p, command); + + const svc = clightning(name, container, image, rest, grpc, p2p, nodeCommand); + // add ENABLE_TOR variable + svc.environment = { + ...svc.environment, + ENABLE_TOR: node.enableTor ? 'true' : 'false', + }; this.addService(svc); } @@ -141,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); } @@ -166,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/lib/lightning/clightning/clightningService.ts b/src/lib/lightning/clightning/clightningService.ts index fb38c473d7..ab48eef7d2 100644 --- a/src/lib/lightning/clightning/clightningService.ts +++ b/src/lib/lightning/clightning/clightningService.ts @@ -49,12 +49,22 @@ export interface CachedChannelStatus { export class CLightningService implements LightningService { async getInfo(node: LightningNode): Promise { 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/resources/onion.png b/src/resources/onion.png new file mode 100644 index 0000000000..364072b23e Binary files /dev/null and b/src/resources/onion.png differ 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/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/designer.spec.ts b/src/store/models/designer.spec.ts index 32e4fedd9b..272af695a4 100644 --- a/src/store/models/designer.spec.ts +++ b/src/store/models/designer.spec.ts @@ -983,5 +983,90 @@ describe('Designer model', () => { expect(firstChart().scale).toEqual(2); }); }); + + describe('Enable 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 do nothing if the chart does not exist', async () => { + 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); + }); + + 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/designer.ts b/src/store/models/designer.ts index 2e85f9f38a..40c217f25c 100644 --- a/src/store/models/designer.ts +++ b/src/store/models/designer.ts @@ -52,6 +52,8 @@ export interface DesignerModel { renameNode: Action; onLinkCompleteListener: ThunkOn; onCanvasDropListener: ThunkOn; + onNetworkToggleTor: ActionOn; + onNodeToggleTor: ActionOn; zoomIn: Action; zoomOut: Action; zoomReset: Action; @@ -604,6 +606,43 @@ 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 tor property in the chart + Object.keys(chart.nodes).forEach(nodeName => { + const node = chart.nodes[nodeName]; + if (node.type === 'lightning' || node.type === 'bitcoin') { + node.properties = { + ...node.properties, + tor: enabled, + }; + } + }); + } + }, + ), + 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 b60125d10e..33eee42429 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', () => { @@ -1073,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', () => { @@ -1291,6 +1336,231 @@ 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(); + }); + + 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', () => { it('should remove a network', async () => { expect(store.getState().network.networks).toHaveLength(0); diff --git a/src/store/models/network.ts b/src/store/models/network.ts index a28fef4842..7d1245fc61 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, @@ -33,6 +34,7 @@ import { importNetworkFromZip, OpenPorts, renameNode, + supportsTor, zipNetwork, } from 'utils/network'; import { prefixTranslation } from 'utils/translate'; @@ -236,6 +238,25 @@ export interface NetworkModel { RootModel, Promise >; + setAllNodesTor: Action; + toggleTorForNetwork: Thunk< + NetworkModel, + { networkId: number; enabled: boolean }, + StoreInjections, + RootModel, + Promise + >; + setNodeTor: Action< + NetworkModel, + { networkId: number; nodeName: string; enabled: boolean } + >; + toggleTorForNode: Thunk< + NetworkModel, + { node: CommonNode; enabled: boolean }, + StoreInjections, + RootModel, + Promise + >; } const networkModel: NetworkModel = { @@ -366,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: '' }; @@ -448,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); @@ -459,7 +488,24 @@ 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 (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({ + 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); @@ -905,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 }), @@ -933,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)); @@ -944,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); }) @@ -1270,6 +1318,77 @@ const networkModel: NetworkModel = { throw e; } }), + 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( + 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.setAllNodesTor({ networkId, enabled }); + + await actions.save(); + network = getState().networks.find(n => n.id === networkId) as Network; + 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; 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/chart.ts b/src/utils/chart.ts index d99038f0dc..b045d074ba 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, }, }; @@ -146,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.spec.ts b/src/utils/network.spec.ts index c7024963a1..a79e112674 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, 'tapd' 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'); + }); + }); }); diff --git a/src/utils/network.ts b/src/utils/network.ts index 25f1bda493..0f8bb5d3d9 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -690,6 +690,148 @@ export const renameNode = async (network: Network, node: AnyNode, newName: strin } }; +/** + * Get Tor flags for a specific implementation + */ + +const getTorFlags = (implementation: NodeImplementation): string[] => { + switch (implementation) { + 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', + ]; + 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 'eclair': + return [ + '--tor.enabled=true', + '--tor.auth=safecookie', + '--socks5.enabled=true', + '--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', + ]; + 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 + 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 === '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(); + // Remove clearnet addr bindings, but keep internal Docker addr + return !(trimmed.startsWith('--addr=') && !trimmed.includes('statictor')); + }); + } + + 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; +}; + +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 (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 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(),