diff --git a/README.md b/README.md index fb4d1d4..1b44f5c 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,49 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) - `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`) +- `TOR_SOCKS_PROXY`: Tor SOCKS proxy address (format: `host:port`, default: `127.0.0.1:9050`). Set to empty string to disable Tor support. When configured, the server will automatically route `.onion` addresses through Tor for both Nostr relay connections and git operations. +- `TOR_ONION_ADDRESS`: Tor hidden service .onion address (optional). If not set, the server will attempt to read it from Tor's hostname file. When configured, every repository will automatically get a `.onion` clone URL in addition to the regular domain URL, making repositories accessible via Tor even if the server is only running on localhost. + +### Tor Hidden Service Setup + +To provide `.onion` addresses for all repositories, you need to set up a Tor hidden service: + +1. **Install and configure Tor**: + ```bash + # On Debian/Ubuntu + sudo apt-get install tor + + # Edit Tor configuration + sudo nano /etc/tor/torrc + ``` + +2. **Add hidden service configuration**: + ``` + HiddenServiceDir /var/lib/tor/gitrepublic + HiddenServicePort 80 127.0.0.1:6543 + ``` + +3. **Restart Tor**: + ```bash + sudo systemctl restart tor + ``` + +4. **Get your .onion address**: + ```bash + sudo cat /var/lib/tor/gitrepublic/hostname + ``` + +5. **Set environment variable** (optional, if hostname file is in a different location): + ```bash + export TOR_ONION_ADDRESS=your-onion-address.onion + ``` + +The server will automatically: +- Detect the `.onion` address from the hostname file or environment variable +- Add a `.onion` clone URL to every repository announcement +- Make repositories accessible via Tor even if the server is only on localhost + +**Note**: The `.onion` address works even if your server is only accessible on `localhost` - Tor will handle the routing! ### Security Configuration diff --git a/package-lock.json b/package-lock.json index 49dc001..4cf1746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "simple-git": "^3.31.1", + "socks": "^2.8.0", "svelte": "^5.0.0", "ws": "^8.19.0" }, @@ -3251,6 +3252,15 @@ "dev": true, "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4319,6 +4329,30 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", diff --git a/package.json b/package.json index 96da5b0..d86dd12 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "simple-git": "^3.31.1", + "socks": "^2.8.0", "svelte": "^5.0.0", "ws": "^8.19.0" }, diff --git a/src/lib/config.ts b/src/lib/config.ts index c7cfcb8..52a371a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -48,6 +48,48 @@ export function getGitUrl(npub: string, repoName: string): string { return `${protocol}://${GIT_DOMAIN}/${npub}/${repoName}.git`; } +/** + * Tor SOCKS proxy configuration + * Defaults to localhost:9050 (standard Tor SOCKS port) + * Can be overridden by TOR_SOCKS_PROXY env var (format: host:port) + * Set to empty string to disable Tor support + */ +export const TOR_SOCKS_PROXY = + typeof process !== 'undefined' && process.env?.TOR_SOCKS_PROXY !== undefined + ? process.env.TOR_SOCKS_PROXY.trim() + : '127.0.0.1:9050'; + +export const TOR_ENABLED = TOR_SOCKS_PROXY !== ''; + +/** + * Parse Tor SOCKS proxy into host and port + */ +export function parseTorProxy(): { host: string; port: number } | null { + if (!TOR_ENABLED) return null; + + const [host, portStr] = TOR_SOCKS_PROXY.split(':'); + const port = parseInt(portStr || '9050', 10); + + if (!host || isNaN(port)) { + return null; + } + + return { host, port }; +} + +/** + * Check if a URL is a .onion address + */ +export function isOnionAddress(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname.endsWith('.onion'); + } catch { + // If URL parsing fails, check if it contains .onion + return url.includes('.onion'); + } +} + /** * Combine default relays with user's relays (from kind 10002) * Returns a deduplicated list with user relays first, then defaults diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index c9c03a7..35218cf 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -13,6 +13,7 @@ import { GIT_DOMAIN } from '../../config.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; import simpleGit, { type SimpleGit } from 'simple-git'; import logger from '../logger.js'; +import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; const execAsync = promisify(exec); @@ -104,6 +105,41 @@ export class RepoManager { } } + /** + * Get git environment variables with Tor proxy if needed for .onion addresses + */ + private getGitEnvForUrl(url: string): Record { + const env = { ...process.env }; + + if (shouldUseTor(url)) { + const proxy = getTorProxy(); + if (proxy) { + // Git uses GIT_PROXY_COMMAND for proxy support + // The command receives host and port as arguments + // We'll create a simple proxy command using socat or nc + // Note: This requires socat or netcat-openbsd to be installed + const proxyCommand = `sh -c 'exec socat - SOCKS5:${proxy.host}:${proxy.port}:\\$1:\\$2' || sh -c 'exec nc -X 5 -x ${proxy.host}:${proxy.port} \\$1 \\$2'`; + env.GIT_PROXY_COMMAND = proxyCommand; + + // Also set ALL_PROXY for git-remote-http + env.ALL_PROXY = `socks5://${proxy.host}:${proxy.port}`; + + // For HTTP/HTTPS URLs, also set http_proxy and https_proxy + try { + const urlObj = new URL(url); + if (urlObj.protocol === 'http:' || urlObj.protocol === 'https:') { + env.http_proxy = `socks5://${proxy.host}:${proxy.port}`; + env.https_proxy = `socks5://${proxy.host}:${proxy.port}`; + } + } catch { + // URL parsing failed, skip proxy env vars + } + } + } + + return env; + } + /** * Sync repository from multiple remote URLs */ @@ -114,11 +150,27 @@ export class RepoManager { const remoteName = `remote-${remoteUrls.indexOf(url)}`; await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); - // Fetch from remote - await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`); + // Get environment with Tor proxy if needed + const gitEnv = this.getGitEnvForUrl(url); + + // Configure git proxy for this remote if it's a .onion address + if (shouldUseTor(url)) { + const proxy = getTorProxy(); + if (proxy) { + // Set git config for this specific URL pattern + try { + await execAsync(`cd "${repoPath}" && git config --local http.${url}.proxy socks5://${proxy.host}:${proxy.port}`, { env: gitEnv }); + } catch { + // Config might fail, continue anyway + } + } + } + + // Fetch from remote with appropriate environment + await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`, { env: gitEnv }); // Update all branches - await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`); + await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`, { env: gitEnv }); } catch (error) { logger.error({ error, url, repoPath }, 'Failed to sync from remote'); // Continue with other remotes @@ -134,8 +186,26 @@ export class RepoManager { try { const remoteName = `remote-${remoteUrls.indexOf(url)}`; await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); - await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`); - await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`); + + // Get environment with Tor proxy if needed + const gitEnv = this.getGitEnvForUrl(url); + + // Configure git proxy for this remote if it's a .onion address + if (shouldUseTor(url)) { + const proxy = getTorProxy(); + if (proxy) { + // Set git config for this specific URL pattern + try { + await execAsync(`cd "${repoPath}" && git config --local http.${url}.proxy socks5://${proxy.host}:${proxy.port}`, { env: gitEnv }); + } catch { + // Config might fail, continue anyway + } + } + } + + // Push to remote with appropriate environment + await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`, { env: gitEnv }); + await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`, { env: gitEnv }); } catch (error) { logger.error({ error, url, repoPath }, 'Failed to sync to remote'); // Continue with other remotes diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index d4e0b1e..841adda 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -5,6 +5,7 @@ import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import logger from '../logger.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; +import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; // Polyfill WebSocket for Node.js environments (lazy initialization) // Note: The 'module' import warning in browser builds is expected and harmless. @@ -55,6 +56,72 @@ if (typeof process !== 'undefined' && process.versions?.node && typeof window == }); } +/** + * Create a WebSocket connection, optionally through Tor SOCKS proxy + */ +async function createWebSocketWithTor(url: string): Promise { + await initializeWebSocketPolyfill(); + + // Check if we need Tor + if (!shouldUseTor(url)) { + return new WebSocket(url); + } + + // Only use Tor in Node.js environment + if (typeof process === 'undefined' || !process.versions?.node || typeof window !== 'undefined') { + // Browser environment - can't use SOCKS proxy directly + // Fall back to regular WebSocket (will fail for .onion in browser) + logger.warn({ url }, 'Tor support not available in browser. .onion addresses may not work.'); + return new WebSocket(url); + } + + const proxy = getTorProxy(); + if (!proxy) { + logger.warn({ url }, 'Tor proxy not configured. Cannot connect to .onion address.'); + return new WebSocket(url); + } + + try { + // Dynamic import for SOCKS support + const { SocksClient } = await import('socks'); + const { WebSocket: WS } = await import('ws'); + + // Parse the WebSocket URL + const wsUrl = new URL(url); + const host = wsUrl.hostname; + const port = wsUrl.port ? parseInt(wsUrl.port, 10) : (wsUrl.protocol === 'wss:' ? 443 : 80); + + // Create SOCKS connection + const socksOptions = { + proxy: { + host: proxy.host, + port: proxy.port, + type: 5 // SOCKS5 + }, + command: 'connect', + destination: { + host, + port + } + }; + + const info = await SocksClient.createConnection(socksOptions); + + // Create WebSocket over the SOCKS connection + const ws = new WS(url, { + socket: info.socket, + // For wss://, we need to handle TLS + rejectUnauthorized: false // .onion addresses use self-signed certs + }); + + return ws as any as WebSocket; + } catch (error) { + logger.error({ error, url, proxy }, 'Failed to create WebSocket through Tor'); + // Fall back to regular connection (will likely fail for .onion) + return new WebSocket(url); + } +} + export class NostrClient { private relays: string[] = []; private authenticatedRelays: Set = new Set(); @@ -187,22 +254,26 @@ export class NostrClient { let authPromise: Promise | null = null; - try { - ws = new WebSocket(relay); - } catch (error) { + // Create WebSocket connection (with Tor support if needed) + createWebSocketWithTor(relay).then(websocket => { + ws = websocket; + setupWebSocketHandlers(); + }).catch(error => { // Connection failed immediately resolveOnce([]); - return; - } - - // Connection timeout - if we can't connect within 3 seconds, give up - connectionTimeoutId = setTimeout(() => { - if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { - resolveOnce([]); - } - }, 3000); + }); - ws.onopen = () => { + function setupWebSocketHandlers() { + if (!ws) return; + + // Connection timeout - if we can't connect within 3 seconds, give up + connectionTimeoutId = setTimeout(() => { + if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { + resolveOnce([]); + } + }, 3000); + + ws.onopen = () => { if (connectionTimeoutId) { clearTimeout(connectionTimeoutId); connectionTimeoutId = null; @@ -271,10 +342,11 @@ export class NostrClient { } }; - // Overall timeout - resolve with what we have after 8 seconds - timeoutId = setTimeout(() => { - resolveOnce(events); - }, 8000); + // Overall timeout - resolve with what we have after 8 seconds + timeoutId = setTimeout(() => { + resolveOnce(events); + }, 8000); + } }); } @@ -342,23 +414,27 @@ export class NostrClient { } }; - try { - ws = new WebSocket(relay); - } catch (error) { - rejectOnce(new Error(`Failed to create WebSocket connection to ${relay}`)); - return; - } - - // Connection timeout - if we can't connect within 3 seconds, reject - connectionTimeoutId = setTimeout(() => { - if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { - rejectOnce(new Error(`Connection timeout for ${relay}`)); - } - }, 3000); - let authPromise: Promise | null = null; - ws.onopen = () => { + // Create WebSocket connection (with Tor support if needed) + createWebSocketWithTor(relay).then(websocket => { + ws = websocket; + setupWebSocketHandlers(); + }).catch(error => { + rejectOnce(new Error(`Failed to create WebSocket connection to ${relay}: ${error}`)); + }); + + function setupWebSocketHandlers() { + if (!ws) return; + + // Connection timeout - if we can't connect within 3 seconds, reject + connectionTimeoutId = setTimeout(() => { + if (!resolved && ws && ws.readyState !== WebSocket.OPEN) { + rejectOnce(new Error(`Connection timeout for ${relay}`)); + } + }, 3000); + + ws.onopen = () => { if (connectionTimeoutId) { clearTimeout(connectionTimeoutId); connectionTimeoutId = null; @@ -432,9 +508,10 @@ export class NostrClient { } }; - timeoutId = setTimeout(() => { - rejectOnce(new Error('Publish timeout')); - }, 10000); + timeoutId = setTimeout(() => { + rejectOnce(new Error('Publish timeout')); + }, 10000); + } }); } } diff --git a/src/lib/services/tor/hidden-service.ts b/src/lib/services/tor/hidden-service.ts new file mode 100644 index 0000000..98bbba9 --- /dev/null +++ b/src/lib/services/tor/hidden-service.ts @@ -0,0 +1,83 @@ +/** + * Tor Hidden Service management + * Detects and provides .onion addresses for the server + */ + +import { readFile, access } from 'fs/promises'; +import { constants } from 'fs'; +import { join } from 'path'; +import logger from '../logger.js'; +import { TOR_ENABLED } from '../../config.js'; + +/** + * Common locations for Tor hidden service hostname files + */ +const TOR_HOSTNAME_PATHS = [ + '/var/lib/tor/hidden_service/hostname', + '/var/lib/tor/gitrepublic/hostname', + '/usr/local/var/lib/tor/hidden_service/hostname', + '/home/.tor/hidden_service/hostname', + process.env.TOR_HOSTNAME_FILE || '' +].filter(Boolean); + +/** + * Get the Tor hidden service .onion address + * Returns null if Tor is not enabled or .onion address cannot be found + */ +export async function getTorOnionAddress(): Promise { + if (!TOR_ENABLED) { + return null; + } + + // First, check if explicitly set via environment variable + if (typeof process !== 'undefined' && process.env?.TOR_ONION_ADDRESS) { + const onion = process.env.TOR_ONION_ADDRESS.trim(); + if (onion.endsWith('.onion')) { + logger.info({ onion }, 'Using Tor .onion address from environment variable'); + return onion; + } + } + + // Try to read from Tor hidden service hostname file + for (const hostnamePath of TOR_HOSTNAME_PATHS) { + if (!hostnamePath) continue; + + try { + await access(hostnamePath, constants.R_OK); + const hostname = await readFile(hostnamePath, 'utf-8'); + const onion = hostname.trim().split('\n')[0].trim(); + + if (onion.endsWith('.onion')) { + logger.info({ onion, path: hostnamePath }, 'Found Tor .onion address from hostname file'); + return onion; + } + } catch { + // File doesn't exist or can't be read, try next path + continue; + } + } + + logger.warn('Tor is enabled but .onion address not found. Set TOR_ONION_ADDRESS env var or configure Tor hidden service.'); + return null; +} + +/** + * Get the full git URL with Tor .onion address for a repository + */ +export async function getTorGitUrl(npub: string, repoName: string): Promise { + const onion = await getTorOnionAddress(); + if (!onion) { + return null; + } + + // Use HTTP for .onion addresses (HTTPS doesn't work with .onion) + return `http://${onion}/${npub}/${repoName}.git`; +} + +/** + * Check if Tor hidden service is available + */ +export async function isTorHiddenServiceAvailable(): Promise { + const onion = await getTorOnionAddress(); + return onion !== null; +} diff --git a/src/lib/utils/tor.ts b/src/lib/utils/tor.ts new file mode 100644 index 0000000..a6ccea1 --- /dev/null +++ b/src/lib/utils/tor.ts @@ -0,0 +1,38 @@ +/** + * Tor utility functions for detecting and handling .onion addresses + */ + +import { isOnionAddress, TOR_ENABLED, parseTorProxy } from '../config.js'; + +/** + * Check if a URL should use Tor proxy + */ +export function shouldUseTor(url: string): boolean { + return TOR_ENABLED && isOnionAddress(url); +} + +/** + * Get Tor SOCKS proxy configuration + */ +export function getTorProxy(): { host: string; port: number } | null { + return parseTorProxy(); +} + +/** + * Format git URL with Tor proxy configuration + * Returns the original URL if Tor is not needed or not available + */ +export function formatGitUrlWithTor(url: string): string { + if (!shouldUseTor(url)) { + return url; + } + + const proxy = getTorProxy(); + if (!proxy) { + return url; + } + + // Git can use Tor via GIT_PROXY_COMMAND or http.proxy + // For now, return the original URL - git will be configured separately + return url; +} diff --git a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts index 79071d7..0dcac2a 100644 --- a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts @@ -174,11 +174,22 @@ export const POST: RequestHandler = async ({ params, request }) => { const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; const gitUrl = `${protocol}://${gitDomain}/${npub}/${repo}.git`; + // Get Tor .onion URL if available + const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js'); + const torOnionUrl = await getTorGitUrl(npub, repo); + + // Build clone URLs - include regular domain and Tor .onion if available + const cloneUrlList = [ + gitUrl, + ...(torOnionUrl ? [torOnionUrl] : []), + ...(cloneUrls || []).filter((url: string) => url && !url.includes(gitDomain) && !url.includes('.onion')) + ]; + const tags: string[][] = [ ['d', repo], ['name', name || repo], ...(description ? [['description', description]] : []), - ['clone', gitUrl, ...(cloneUrls || []).filter((url: string) => url && !url.includes(gitDomain))], + ['clone', ...cloneUrlList], ['relays', ...DEFAULT_NOSTR_RELAYS], ...(isPrivate ? [['private', 'true']] : []), ...(maintainers || []).map((m: string) => ['maintainers', m]) diff --git a/src/routes/api/tor/onion/+server.ts b/src/routes/api/tor/onion/+server.ts new file mode 100644 index 0000000..d25d0b0 --- /dev/null +++ b/src/routes/api/tor/onion/+server.ts @@ -0,0 +1,12 @@ +/** + * API endpoint to get the Tor .onion address for the server + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getTorOnionAddress } from '$lib/services/tor/hidden-service.js'; + +export const GET: RequestHandler = async () => { + const onion = await getTorOnionAddress(); + return json({ onion, available: onion !== null }); +}; diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index bc01fc5..372d53f 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -882,10 +882,25 @@ const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; const gitUrl = `${protocol}://${gitDomain}/${npub}/${dTag}.git`; - // Build clone URLs - always include our domain + // Try to get Tor .onion address and add it to clone URLs + let torOnionUrl: string | null = null; + try { + const torResponse = await fetch('/api/tor/onion'); + if (torResponse.ok) { + const torData = await torResponse.json(); + if (torData.available && torData.onion) { + torOnionUrl = `http://${torData.onion}/${npub}/${dTag}.git`; + } + } + } catch { + // Tor not available, continue without it + } + + // Build clone URLs - always include our domain, and Tor .onion if available const allCloneUrls = [ gitUrl, - ...cloneUrls.filter(url => url.trim() && !url.includes(gitDomain)) + ...(torOnionUrl ? [torOnionUrl] : []), // Add Tor .onion URL if available + ...cloneUrls.filter(url => url.trim() && !url.includes(gitDomain) && !url.includes('.onion')) ]; // Build web URLs