diff --git a/docs/authentication.md b/docs/authentication.md index 52692206..ebe27886 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -75,6 +75,35 @@ npm run dev -- --no-auth **Best for:** Local development, trusted networks, or demo environments +### 5. Tailscale Serve Integration Mode + +**Usage:** Enable integrated Tailscale Serve support +```bash +npm run dev -- --enable-tailscale-serve +# or +./vibetunnel --enable-tailscale-serve +``` + +**Behavior:** +- Automatically starts `tailscale serve` as a background process +- Forces server to bind to localhost (127.0.0.1) for security +- Enables Tailscale identity header authentication +- Provides HTTPS access without exposing ports +- No manual Tailscale configuration required + +**macOS App Integration:** +- Toggle available in Settings → Remote Access → Tailscale Integration +- Shows HTTPS URL in menu bar when enabled +- Automatically manages the Tailscale Serve process lifecycle + +**Security Model:** +- Server only listens on localhost when enabled +- All external access goes through Tailscale's secure proxy +- Identity headers are automatically validated +- No risk of header spoofing from external sources + +**Best for:** Easy, secure remote access through Tailscale network + ## User Avatar System ### macOS Integration @@ -102,6 +131,7 @@ On non-macOS systems: --enable-ssh-keys Enable SSH key authentication UI and functionality --disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys) --no-auth Disable authentication (auto-login as current user) +--enable-tailscale-serve Enable Tailscale Serve integration (auto-starts proxy, forces localhost) # Other options --port Server port (default: 4020) @@ -129,6 +159,10 @@ npm run dev -- --no-auth # High-security production (SSH keys only) ./vibetunnel --disallow-user-password --port 8080 + +# Tailscale Serve integration (secure remote access) +./vibetunnel --enable-tailscale-serve --port 4020 +# No manual configuration needed - everything handled automatically ``` ## Security Considerations @@ -151,6 +185,12 @@ npm run dev -- --no-auth - Suitable for local development or demo purposes - Not recommended for production or public networks +### Tailscale Authentication +- **⚠️ Security Warning:** Only use when bound to localhost +- Requires Tailscale Serve proxy for header injection +- Provides SSO-like experience for Tailscale users +- Headers are trusted only from Tailscale proxy + ## Configuration API ### Frontend Configuration Endpoint @@ -236,6 +276,56 @@ This allows the UI to: - Remove unused keys from authorized_keys - Monitor authentication logs for suspicious activity +## Tailscale Authentication Details + +### How Tailscale Serve Works + +Tailscale Serve acts as a reverse proxy that: +1. Receives requests from your tailnet +2. Adds identity headers based on the authenticated Tailscale user +3. Forwards requests to your local service + +### Identity Headers + +When a request comes through Tailscale Serve, these headers are added: +- `tailscale-user-login`: The user's email address or login +- `tailscale-user-name`: The user's display name +- `tailscale-user-profile-pic`: URL to the user's profile picture + +### Setup Instructions + +1. **Start VibeTunnel with integrated Tailscale Serve:** + ```bash + ./vibetunnel --enable-tailscale-serve --port 4020 + ``` + + Or use the macOS app and enable the toggle in Settings → Remote Access + +2. **Access via Tailscale:** + ``` + https://[your-machine-name].[tailnet-name].ts.net + ``` + +### Security Model + +- VibeTunnel trusts identity headers ONLY from localhost connections +- Tailscale Serve ensures headers cannot be spoofed by external users +- Direct access to VibeTunnel port would allow header forgery +- Always bind to `127.0.0.1` when using Tailscale authentication + +### Integration with Other Auth Modes + +Tailscale Serve integration can be combined with other authentication modes: +```bash +# Tailscale Serve + SSH keys as fallback +./vibetunnel --enable-tailscale-serve --enable-ssh-keys + +# Tailscale Serve + local bypass for scripts +./vibetunnel --enable-tailscale-serve --allow-local-bypass +``` + +**Note**: The `--enable-tailscale-serve` flag automatically manages both the Tailscale proxy and authentication. + ## Implementation Details ### Authentication Flow diff --git a/mac/VibeTunnel/Core/Constants/ErrorMessages.swift b/mac/VibeTunnel/Core/Constants/ErrorMessages.swift index 015773dd..7ba620bd 100644 --- a/mac/VibeTunnel/Core/Constants/ErrorMessages.swift +++ b/mac/VibeTunnel/Core/Constants/ErrorMessages.swift @@ -14,7 +14,8 @@ enum ErrorMessages { static let operationTimeout = "Operation timed out" static let invalidRequest = "Invalid request" static let sessionNameEmpty = "Session name cannot be empty" - static let terminalWindowNotFound = "Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel." + static let terminalWindowNotFound = + "Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel." static func windowNotFoundForSession(_ sessionID: String) -> String { "Could not find window for session \(sessionID)" } @@ -49,7 +50,8 @@ enum ErrorMessages { // MARK: - Keychain Errors - static let keychainSaveError = "Failed to save the auth token to the keychain. Please check your keychain permissions and try again." + static let keychainSaveError = + "Failed to save the auth token to the keychain. Please check your keychain permissions and try again." static let keychainRetrieveError = "Failed to retrieve token from keychain" static let keychainAccessError = "Failed to access auth token. Please try again." static let keychainSaveTokenError = "Failed to save token to keychain" @@ -80,7 +82,8 @@ enum ErrorMessages { "Failed to start ngrok: \(error.localizedDescription)" } - static let ngrokNotInstalled = "ngrok is not installed. Please install it using 'brew install ngrok' or download from ngrok.com" + static let ngrokNotInstalled = + "ngrok is not installed. Please install it using 'brew install ngrok' or download from ngrok.com" static let ngrokAuthTokenMissing = "ngrok auth token is missing. Please add it in Settings" static let invalidNgrokConfiguration = "Invalid ngrok configuration" diff --git a/mac/VibeTunnel/Core/Constants/URLConstants.swift b/mac/VibeTunnel/Core/Constants/URLConstants.swift index 9a133c3a..1bcdbca4 100644 --- a/mac/VibeTunnel/Core/Constants/URLConstants.swift +++ b/mac/VibeTunnel/Core/Constants/URLConstants.swift @@ -32,7 +32,8 @@ enum URLConstants { static let cloudflareFormula = "https://formulae.brew.sh/formula/cloudflared" static let cloudflareReleases = "https://github.com/cloudflare/cloudflared/releases/latest" - static let cloudflareDocs = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" + static let cloudflareDocs = + "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" // MARK: - Update Feed @@ -45,7 +46,8 @@ enum URLConstants { // MARK: - Regular Expressions - static let cloudflareURLPattern = #"https://[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.trycloudflare\.com/?(?:\s|$)"# + static let cloudflareURLPattern = + #"https://[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.trycloudflare\.com/?(?:\s|$)"# // MARK: - Local Server Base diff --git a/mac/VibeTunnel/Core/Helpers/TailscaleURLHelper.swift b/mac/VibeTunnel/Core/Helpers/TailscaleURLHelper.swift new file mode 100644 index 00000000..8a674cec --- /dev/null +++ b/mac/VibeTunnel/Core/Helpers/TailscaleURLHelper.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Helper for constructing Tailscale URLs based on configuration +enum TailscaleURLHelper { + /// Constructs a Tailscale URL based on whether Tailscale Serve is enabled + /// - Parameters: + /// - hostname: The Tailscale hostname + /// - port: The server port + /// - isTailscaleServeEnabled: Whether Tailscale Serve integration is enabled + /// - Returns: The appropriate URL for accessing via Tailscale + static func constructURL(hostname: String, port: String, isTailscaleServeEnabled: Bool) -> URL? { + if isTailscaleServeEnabled { + // When Tailscale Serve is enabled, use HTTPS without port + URL(string: "https://\(hostname)") + } else { + // When Tailscale Serve is disabled, use HTTP with port + URL(string: "http://\(hostname):\(port)") + } + } + + /// Gets the display address for Tailscale based on configuration + /// - Parameters: + /// - hostname: The Tailscale hostname + /// - port: The server port + /// - isTailscaleServeEnabled: Whether Tailscale Serve integration is enabled + /// - Returns: The display string for the Tailscale address + static func displayAddress(hostname: String, port: String, isTailscaleServeEnabled: Bool) -> String { + if isTailscaleServeEnabled { + // When Tailscale Serve is enabled, show hostname only + hostname + } else { + // When Tailscale Serve is disabled, show hostname:port + "\(hostname):\(port)" + } + } +} diff --git a/mac/VibeTunnel/Core/Models/AppConstants.swift b/mac/VibeTunnel/Core/Models/AppConstants.swift index 7467e6be..0c491d27 100644 --- a/mac/VibeTunnel/Core/Models/AppConstants.swift +++ b/mac/VibeTunnel/Core/Models/AppConstants.swift @@ -33,6 +33,9 @@ enum AppConstants { static let showInDock = "showInDock" static let updateChannel = "updateChannel" + /// Remote Access Settings + static let tailscaleServeEnabled = "tailscaleServeEnabled" + // New Session keys static let newSessionCommand = "NewSession.command" static let newSessionWorkingDirectory = "NewSession.workingDirectory" diff --git a/mac/VibeTunnel/Core/Services/AutocompleteService.swift b/mac/VibeTunnel/Core/Services/AutocompleteService.swift index 078e95b2..30407eef 100644 --- a/mac/VibeTunnel/Core/Services/AutocompleteService.swift +++ b/mac/VibeTunnel/Core/Services/AutocompleteService.swift @@ -353,8 +353,9 @@ class AutocompleteService { for (index, suggestion) in suggestions.enumerated() where suggestion.type == .directory { group.addTask { [gitMonitor = self.gitMonitor] in // Expand path for Git lookup - let expandedPath = NSString(string: suggestion.suggestion - .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let expandedPath = NSString( + string: suggestion.suggestion + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) ).expandingTildeInPath let gitInfo = await gitMonitor.findRepository(for: expandedPath).map { repo in GitInfo( diff --git a/mac/VibeTunnel/Core/Services/BunServer.swift b/mac/VibeTunnel/Core/Services/BunServer.swift index 91259186..537e282e 100644 --- a/mac/VibeTunnel/Core/Services/BunServer.swift +++ b/mac/VibeTunnel/Core/Services/BunServer.swift @@ -226,6 +226,23 @@ final class BunServer { // Repository base path is now loaded from config.json by the server // No CLI argument needed + // Add Tailscale Serve integration if enabled + let tailscaleServeEnabled = UserDefaults.standard + .bool(forKey: AppConstants.UserDefaultsKeys.tailscaleServeEnabled) + if tailscaleServeEnabled { + vibetunnelArgs.append("--enable-tailscale-serve") + logger.info("Tailscale Serve integration enabled") + + // Force localhost binding for security when using Tailscale Serve + if bindAddress == "0.0.0.0" { + logger.warning("Overriding bind address to localhost for Tailscale Serve security") + // Update the vibetunnelArgs that were already set above + if let bindIndex = vibetunnelArgs.firstIndex(of: "--bind") { + vibetunnelArgs[bindIndex + 1] = "127.0.0.1" + } + } + } + // Create wrapper to run vibetunnel with parent death monitoring AND crash detection let parentPid = ProcessInfo.processInfo.processIdentifier @@ -415,9 +432,19 @@ final class BunServer { let authConfig = AuthConfig.current() // Build the dev server arguments + var effectiveBindAddress = bindAddress + + // Check if Tailscale Serve is enabled and force localhost binding + let tailscaleServeEnabled = UserDefaults.standard + .bool(forKey: AppConstants.UserDefaultsKeys.tailscaleServeEnabled) + if tailscaleServeEnabled && bindAddress == "0.0.0.0" { + logger.warning("Overriding bind address to localhost for Tailscale Serve security") + effectiveBindAddress = "127.0.0.1" + } + let devArgs = devServerManager.buildDevServerArguments( port: port, - bindAddress: bindAddress, + bindAddress: effectiveBindAddress, authMode: authConfig.mode, localToken: localToken ) diff --git a/mac/VibeTunnel/Core/Services/DevServerManager.swift b/mac/VibeTunnel/Core/Services/DevServerManager.swift index 92f8ed0e..65bf1e97 100644 --- a/mac/VibeTunnel/Core/Services/DevServerManager.swift +++ b/mac/VibeTunnel/Core/Services/DevServerManager.swift @@ -211,6 +211,14 @@ final class DevServerManager: ObservableObject { args.append(contentsOf: ["--allow-local-bypass", "--local-auth-token", token]) } + // Add Tailscale Serve integration if enabled + let tailscaleServeEnabled = UserDefaults.standard + .bool(forKey: AppConstants.UserDefaultsKeys.tailscaleServeEnabled) + if tailscaleServeEnabled { + args.append("--enable-tailscale-serve") + logger.info("Tailscale Serve integration enabled") + } + return args } } diff --git a/mac/VibeTunnel/Core/Services/TailscaleServeStatusService.swift b/mac/VibeTunnel/Core/Services/TailscaleServeStatusService.swift new file mode 100644 index 00000000..4284fbd2 --- /dev/null +++ b/mac/VibeTunnel/Core/Services/TailscaleServeStatusService.swift @@ -0,0 +1,123 @@ +import Foundation +import os.log +import SwiftUI + +/// Service to fetch Tailscale Serve status from the server +@MainActor +@Observable +final class TailscaleServeStatusService { + static let shared = TailscaleServeStatusService() + + var isRunning = false + var lastError: String? + var startTime: Date? + var isLoading = false + + private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "TailscaleServeStatus") + private var updateTimer: Timer? + + private init() {} + + /// Start polling for status updates + func startMonitoring() { + // Initial fetch + Task { + await fetchStatus() + } + + // Set up periodic updates + updateTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in + Task { @MainActor in + await self.fetchStatus() + } + } + } + + /// Stop polling for status updates + func stopMonitoring() { + updateTimer?.invalidate() + updateTimer = nil + } + + /// Fetch the current Tailscale Serve status + @MainActor + func fetchStatus() async { + isLoading = true + defer { isLoading = false } + + // Get server port + let port = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.serverPort) ?? "4020" + let urlString = "http://localhost:\(port)/api/sessions/tailscale/status" + + guard let url = URL(string: urlString) else { + logger.error("Invalid URL for Tailscale status endpoint") + return + } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid response type") + isRunning = false + lastError = "Invalid server response" + return + } + + guard httpResponse.statusCode == 200 else { + logger.error("HTTP error: \(httpResponse.statusCode)") + // If we get a non-200 response, there's an issue with the endpoint + isRunning = false + lastError = "Unable to check status (HTTP \(httpResponse.statusCode))" + return + } + + let decoder = JSONDecoder() + // Use custom date decoder to handle ISO8601 with fractional seconds + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = formatter.date(from: dateString) { + return date + } + // Fallback to standard ISO8601 without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)") + } + + let status = try decoder.decode(TailscaleServeStatus.self, from: data) + + // Update published properties + isRunning = status.isRunning + lastError = status.lastError + startTime = status.startTime + + logger.debug("Tailscale Serve status - Running: \(status.isRunning), Error: \(status.lastError ?? "none")") + + } catch { + logger.error("Failed to fetch Tailscale Serve status: \(error.localizedDescription)") + // On error, assume not running + isRunning = false + // Keep error messages concise to prevent UI jumping + if error.localizedDescription.contains("couldn't be read") { + lastError = "Status check failed" + } else { + lastError = error.localizedDescription + } + } + } +} + +/// Response model for Tailscale Serve status +struct TailscaleServeStatus: Codable { + let isRunning: Bool + let port: Int? + let error: String? + let lastError: String? + let startTime: Date? +} diff --git a/mac/VibeTunnel/Presentation/Components/GitBranchWorktreeSelector.swift b/mac/VibeTunnel/Presentation/Components/GitBranchWorktreeSelector.swift index fc3d9be4..890c0700 100644 --- a/mac/VibeTunnel/Presentation/Components/GitBranchWorktreeSelector.swift +++ b/mac/VibeTunnel/Presentation/Components/GitBranchWorktreeSelector.swift @@ -186,8 +186,9 @@ struct GitBranchWorktreeSelector: View { } .font(.system(size: 11)) .buttonStyle(.borderedProminent) - .disabled(newBranchName.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || isCreatingWorktree + .disabled( + newBranchName.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty || isCreatingWorktree ) } diff --git a/mac/VibeTunnel/Presentation/Components/Menu/MenuActionBar.swift b/mac/VibeTunnel/Presentation/Components/Menu/MenuActionBar.swift index fd003480..308c9a1c 100644 --- a/mac/VibeTunnel/Presentation/Components/Menu/MenuActionBar.swift +++ b/mac/VibeTunnel/Presentation/Components/Menu/MenuActionBar.swift @@ -37,8 +37,9 @@ struct MenuActionBar: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 8) - .fill(isHoveringNewSession ? AppColors.Fallback.controlBackground(for: colorScheme) - .opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear + .fill( + isHoveringNewSession ? AppColors.Fallback.controlBackground(for: colorScheme) + .opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear ) .scaleEffect(isHoveringNewSession ? 1.08 : 1.0) .animation(.easeInOut(duration: 0.15), value: isHoveringNewSession) @@ -69,8 +70,9 @@ struct MenuActionBar: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 8) - .fill(isHoveringSettings ? AppColors.Fallback.controlBackground(for: colorScheme) - .opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear + .fill( + isHoveringSettings ? AppColors.Fallback.controlBackground(for: colorScheme) + .opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear ) .scaleEffect(isHoveringSettings ? 1.08 : 1.0) .animation(.easeInOut(duration: 0.15), value: isHoveringSettings) @@ -103,8 +105,9 @@ struct MenuActionBar: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 8) - .fill(isHoveringQuit ? AppColors.Fallback.controlBackground(for: colorScheme) - .opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear + .fill( + isHoveringQuit ? AppColors.Fallback.controlBackground(for: colorScheme) + .opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear ) .scaleEffect(isHoveringQuit ? 1.08 : 1.0) .animation(.easeInOut(duration: 0.15), value: isHoveringQuit) diff --git a/mac/VibeTunnel/Presentation/Components/Menu/ServerInfoSection.swift b/mac/VibeTunnel/Presentation/Components/Menu/ServerInfoSection.swift index 5c68785a..387e005e 100644 --- a/mac/VibeTunnel/Presentation/Components/Menu/ServerInfoSection.swift +++ b/mac/VibeTunnel/Presentation/Components/Menu/ServerInfoSection.swift @@ -68,11 +68,21 @@ struct ServerInfoHeader: View { } if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname { + let isTailscaleServeEnabled = UserDefaults.standard + .bool(forKey: AppConstants.UserDefaultsKeys.tailscaleServeEnabled) ServerAddressRow( icon: "shield", label: "Tailscale:", - address: hostname, - url: URL(string: "http://\(hostname):\(serverManager.port)") + address: TailscaleURLHelper.displayAddress( + hostname: hostname, + port: serverManager.port, + isTailscaleServeEnabled: isTailscaleServeEnabled + ), + url: TailscaleURLHelper.constructURL( + hostname: hostname, + port: serverManager.port, + isTailscaleServeEnabled: isTailscaleServeEnabled + ) ) } } @@ -181,11 +191,6 @@ struct ServerAddressRow: View { return providedUrl.absoluteString } - // For Tailscale, return the full URL - if label == "Tailscale:" && !address.isEmpty { - return "http://\(address):\(serverManager.port)" - } - // For local addresses, build the full URL if computedAddress.starts(with: "127.0.0.1:") { return "http://\(computedAddress)" diff --git a/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift b/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift index 7921845a..61d20b40 100644 --- a/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift +++ b/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift @@ -153,8 +153,9 @@ struct SessionRow: View { .padding(4) .background( RoundedRectangle(cornerRadius: 4) - .fill(isHoveringFolder ? AppColors.Fallback.controlBackground(for: colorScheme) - .opacity(0.3) : Color.clear + .fill( + isHoveringFolder ? AppColors.Fallback.controlBackground(for: colorScheme) + .opacity(0.3) : Color.clear ) ) .overlay( diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index 417bc626..12864a2e 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -272,8 +272,9 @@ struct NewSessionForm: View { }) .background( RoundedRectangle(cornerRadius: 6) - .fill(command == cmd.command ? Color.accentColor.opacity(0.15) : Color.primary - .opacity(0.05) + .fill( + command == cmd.command ? Color.accentColor.opacity(0.15) : Color.primary + .opacity(0.05) ) ) .overlay( @@ -498,8 +499,10 @@ struct NewSessionForm: View { await MainActor.run { branchSwitchWarning = isUncommittedChanges - ? "Cannot switch to \(selectedBaseBranch) due to uncommitted changes. Creating session on \(currentBranch)." - : "Failed to switch to \(selectedBaseBranch): \(errorMessage). Creating session on \(currentBranch)." + ? + "Cannot switch to \(selectedBaseBranch) due to uncommitted changes. Creating session on \(currentBranch)." + : + "Failed to switch to \(selectedBaseBranch): \(errorMessage). Creating session on \(currentBranch)." } } } diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index 84c42030..0848a81d 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -13,8 +13,9 @@ import SwiftUI self.highlight(true) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { self - .highlight(AppDelegate.shared?.statusBarController?.menuManager.customWindow? - .isWindowVisible ?? false + .highlight( + AppDelegate.shared?.statusBarController?.menuManager.customWindow? + .isWindowVisible ?? false ) } } diff --git a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift index 5eb1e658..30270e44 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift @@ -23,6 +23,8 @@ struct RemoteAccessSettingsView: View { private var tailscaleService @Environment(CloudflareService.self) private var cloudflareService + @Environment(TailscaleServeStatusService.self) + private var tailscaleServeStatus @Environment(ServerManager.self) private var serverManager @@ -58,7 +60,8 @@ struct RemoteAccessSettingsView: View { TailscaleIntegrationSection( tailscaleService: tailscaleService, serverPort: serverPort, - accessMode: accessMode + accessMode: accessMode, + serverManager: serverManager ) CloudflareIntegrationSection( @@ -93,6 +96,12 @@ struct RemoteAccessSettingsView: View { // Initialize authentication mode from stored value let storedMode = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.authenticationMode) ?? "os" authMode = AuthenticationMode(rawValue: storedMode) ?? .osAuth + // Start monitoring Tailscale Serve status + tailscaleServeStatus.startMonitoring() + } + .onDisappear { + // Stop monitoring when view disappears + tailscaleServeStatus.stopMonitoring() } } .alert("ngrok Authentication Required", isPresented: $showingAuthTokenAlert) { @@ -231,8 +240,13 @@ private struct TailscaleIntegrationSection: View { let tailscaleService: TailscaleService let serverPort: String let accessMode: DashboardAccessMode + let serverManager: ServerManager @State private var statusCheckTimer: Timer? + @AppStorage(AppConstants.UserDefaultsKeys.tailscaleServeEnabled) + private var tailscaleServeEnabled = false + @Environment(TailscaleServeStatusService.self) + private var tailscaleServeStatus private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "TailscaleIntegrationSection") @@ -297,16 +311,63 @@ private struct TailscaleIntegrationSection: View { .controlSize(.small) } } else if tailscaleService.isRunning { + // Tailscale Serve toggle + HStack { + Toggle("Enable Tailscale Serve Integration", isOn: $tailscaleServeEnabled) + .onChange(of: tailscaleServeEnabled) { _, newValue in + logger.info("Tailscale Serve integration \(newValue ? "enabled" : "disabled")") + // Restart server to apply the new setting + Task { + await serverManager.restart() + } + } + + Spacer() + + if tailscaleServeEnabled { + // Show status indicator - fixed height to prevent jumping + HStack(spacing: 4) { + if tailscaleServeStatus.isLoading { + ProgressView() + .scaleEffect(0.7) + } else if tailscaleServeStatus.isRunning { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Running") + .font(.caption) + .foregroundColor(.secondary) + } else if let error = tailscaleServeStatus.lastError { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .help("Error: \(error)") + Text("Error") + .font(.caption) + .foregroundColor(.orange) + } else { + Image(systemName: "circle") + .foregroundColor(.gray) + Text("Starting...") + .font(.caption) + .foregroundColor(.secondary) + } + } + .frame(height: 16) // Fixed height prevents UI jumping + } + } + // Show dashboard URL when running if let hostname = tailscaleService.tailscaleHostname { - let urlString = "http://\(hostname):\(serverPort)" InlineClickableURLView( label: "Access VibeTunnel at:", - url: urlString + url: TailscaleURLHelper.constructURL( + hostname: hostname, + port: serverPort, + isTailscaleServeEnabled: tailscaleServeEnabled + )?.absoluteString ?? "" ) // Show warning if in localhost-only mode - if accessMode == .localhost { + if accessMode == .localhost && !tailscaleServeEnabled { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) @@ -318,6 +379,33 @@ private struct TailscaleIntegrationSection: View { .foregroundStyle(.secondary) } } + + // Show error details if any + if tailscaleServeEnabled, let error = tailscaleServeStatus.lastError { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 12)) + Text("Error: \(error)") + .font(.caption) + .foregroundColor(.orange) + .lineLimit(2) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(4) + } + + // Help text about Tailscale Serve + if tailscaleServeEnabled && tailscaleServeStatus.isRunning { + Text( + "Tailscale Serve provides secure access with automatic authentication using Tailscale identity headers." + ) + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 4) + } } } } diff --git a/mac/VibeTunnel/Presentation/Views/Settings/ServerConfigurationComponents.swift b/mac/VibeTunnel/Presentation/Views/Settings/ServerConfigurationComponents.swift index 7371032e..74df0b19 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/ServerConfigurationComponents.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/ServerConfigurationComponents.swift @@ -85,21 +85,46 @@ struct AccessModeView: View { let localIPAddress: String? let restartServerWithNewBindAddress: () -> Void + @AppStorage(AppConstants.UserDefaultsKeys.tailscaleServeEnabled) + private var tailscaleServeEnabled = false + var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Access Mode") .font(.callout) Spacer() - Picker("", selection: $accessModeString) { - ForEach(DashboardAccessMode.allCases, id: \.rawValue) { mode in - Text(mode.displayName) - .tag(mode.rawValue) + + if tailscaleServeEnabled { + // When Tailscale Serve is enabled, force localhost mode + Text("Localhost") + .foregroundColor(.secondary) + + Image(systemName: "lock.shield.fill") + .foregroundColor(.blue) + .help("Tailscale Serve requires localhost binding for security") + } else { + Picker("", selection: $accessModeString) { + ForEach(DashboardAccessMode.allCases, id: \.rawValue) { mode in + Text(mode.displayName) + .tag(mode.rawValue) + } + } + .labelsHidden() + .onChange(of: accessModeString) { _, _ in + restartServerWithNewBindAddress() } } - .labelsHidden() - .onChange(of: accessModeString) { _, _ in - restartServerWithNewBindAddress() + } + + if tailscaleServeEnabled && accessMode == .network { + HStack(spacing: 4) { + Image(systemName: "info.circle.fill") + .foregroundColor(.blue) + .font(.caption) + Text("Tailscale Serve active - using localhost binding for security") + .font(.caption) + .foregroundColor(.secondary) } } } diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 35e7466e..b232bb0b 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -28,6 +28,7 @@ struct VibeTunnelApp: App { @State var worktreeService = WorktreeService(serverManager: ServerManager.shared) @State var configManager = ConfigManager.shared @State var notificationService = NotificationService.shared + @State var tailscaleServeStatusService = TailscaleServeStatusService.shared init() { // Connect the app delegate to this app instance @@ -113,6 +114,7 @@ struct VibeTunnelApp: App { )) .environment(worktreeService) .environment(notificationService) + .environment(tailscaleServeStatusService) } .commands { CommandGroup(after: .appInfo) { diff --git a/mac/VibeTunnelTests/ServerManagerTests.swift b/mac/VibeTunnelTests/ServerManagerTests.swift index ede7d887..bb6839ea 100644 --- a/mac/VibeTunnelTests/ServerManagerTests.swift +++ b/mac/VibeTunnelTests/ServerManagerTests.swift @@ -169,14 +169,16 @@ final class ServerManagerTests { // Test setting via bind address manager.bindAddress = "127.0.0.1" - #expect(UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues - .localhost + #expect( + UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues + .localhost ) #expect(manager.bindAddress == "127.0.0.1") manager.bindAddress = "0.0.0.0" - #expect(UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues - .network + #expect( + UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues + .network ) #expect(manager.bindAddress == "0.0.0.0") @@ -218,8 +220,9 @@ final class ServerManagerTests { // Bind address should persist #expect(manager.bindAddress == "127.0.0.1") - #expect(UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues - .localhost + #expect( + UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues + .localhost ) // Change to network mode diff --git a/web/scripts/dev.js b/web/scripts/dev.js index 843d8808..5d9dd662 100644 --- a/web/scripts/dev.js +++ b/web/scripts/dev.js @@ -25,11 +25,12 @@ const { values, positionals } = parseArgs({ }, }, allowPositionals: true, + strict: false, // Allow unknown options to be passed through }); const watchServer = !values['client-only']; -// Build server args from parsed values +// Build server args from parsed values and pass through all unknown args const serverArgs = []; if (values.port) { serverArgs.push('--port', values.port); @@ -38,6 +39,72 @@ if (values.bind) { serverArgs.push('--bind', values.bind); } +// Pass through all command line args except the ones we handle +const allArgs = process.argv.slice(2); +const handledArgs = new Set(['--client-only']); + +// Track which indices we've already processed to avoid duplicates +const processedIndices = new Set(); + +// Add indices of handled args to processedIndices +for (let i = 0; i < allArgs.length; i++) { + const arg = allArgs[i]; + if (arg === '--client-only') { + processedIndices.add(i); + } else if (arg === '--port' && values.port) { + processedIndices.add(i); + if (i + 1 < allArgs.length && allArgs[i + 1] === values.port) { + processedIndices.add(i + 1); + } + } else if (arg === '--bind' && values.bind) { + processedIndices.add(i); + if (i + 1 < allArgs.length && allArgs[i + 1] === values.bind) { + processedIndices.add(i + 1); + } + } +} + +// Known boolean flags that don't take values +const booleanFlags = new Set([ + '--verbose', '-v', + '--debug', '-d', + '--quiet', '-q', + '--force', '-f', + '--help', '-h', + '--version', + '--no-auth', + '--inspect', + '--trace-warnings' +]); + +// Add any args that weren't handled by parseArgs +for (let i = 0; i < allArgs.length; i++) { + const arg = allArgs[i]; + + // Skip the '--' separator that pnpm adds + if (arg === '--') { + continue; + } + + // Skip if we've already processed this index + if (processedIndices.has(i)) { + continue; + } + + // Add the argument + serverArgs.push(arg); + + // Check if this is a flag that takes a value + if (arg.startsWith('-') && !booleanFlags.has(arg)) { + // If next arg exists and doesn't start with -, treat it as a value + if (i + 1 < allArgs.length && !allArgs[i + 1].startsWith('-')) { + serverArgs.push(allArgs[i + 1]); + processedIndices.add(i + 1); + i++; // Skip the value in next iteration + } + } +} + // Initial build of assets and CSS console.log('Initial build...'); require('child_process').execSync('node scripts/ensure-dirs.js', { stdio: 'inherit' }); diff --git a/web/src/cli.ts b/web/src/cli.ts index 4e41ad58..faf1059e 100644 --- a/web/src/cli.ts +++ b/web/src/cli.ts @@ -12,6 +12,12 @@ import { closeLogger, createLogger, initLogger, VerbosityLevel } from './server/ import { parseVerbosityFromEnv } from './server/utils/verbosity-parser.js'; import { VERSION } from './server/version.js'; +// Check for version command early - before logger initialization +if (process.argv[2] === 'version') { + console.log(`VibeTunnel Server v${VERSION}`); + process.exit(0); +} + // Initialize logger before anything else // Parse verbosity from environment variables const verbosityLevel = parseVerbosityFromEnv(); diff --git a/web/src/client/app.ts b/web/src/client/app.ts index d14d1bba..c478aa31 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -445,6 +445,18 @@ export class VibeTunnelApp extends LitElement { this.initialLoadComplete = true; return; } + + // Check if user is authenticated via Tailscale + if (authConfig.tailscaleAuth && authConfig.authenticatedUser) { + logger.log('🔒 Authenticated via Tailscale:', authConfig.authenticatedUser); + this.isAuthenticated = true; + this.currentView = 'list'; + await this.initializeServices(noAuthEnabled); // Initialize services with no-auth flag + await this.loadSessions(); // Wait for sessions to load + this.startAutoRefresh(); + this.initialLoadComplete = true; + return; + } } } catch (error) { logger.warn('⚠️ Could not fetch auth config:', error); diff --git a/web/src/server/middleware/auth.test.ts b/web/src/server/middleware/auth.test.ts new file mode 100644 index 00000000..89cf3218 --- /dev/null +++ b/web/src/server/middleware/auth.test.ts @@ -0,0 +1,429 @@ +import type { NextFunction, Response } from 'express'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthService } from '../services/auth-service.js'; +import { type AuthenticatedRequest, createAuthMiddleware } from './auth.js'; + +// Mock the logger +vi.mock('../utils/logger.js', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +describe('Auth Middleware', () => { + let app: express.Express; + let mockAuthService: AuthService; + let mockNext: NextFunction; + let mockRes: Response; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + // Mock auth service + mockAuthService = { + verifyPassword: vi.fn(), + generateAuthToken: vi.fn(), + verifyAuthToken: vi.fn(), + verifyToken: vi.fn(), // Add the correct method name + revokeAuthToken: vi.fn(), + isSSHKeyAuthenticated: vi.fn(), + authenticateWithSSHKey: vi.fn(), + markSSHKeyAuthenticated: vi.fn(), + clearSSHKeyAuthentication: vi.fn(), + } as unknown as AuthService; + + mockNext = vi.fn(); + mockRes = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; + + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('No Auth Mode', () => { + it('should bypass authentication when noAuth is true', async () => { + const middleware = createAuthMiddleware({ noAuth: true }); + + app.use('/api', middleware); + app.get('/api/test', (_req, res) => res.json({ success: true })); + + const response = await request(app).get('/api/test'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + }); + + describe('Tailscale Authentication', () => { + it('should authenticate user with valid Tailscale headers from localhost', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (req: AuthenticatedRequest, res) => { + res.json({ + success: true, + userId: req.userId, + authMethod: req.authMethod, + tailscaleUser: req.tailscaleUser, + }); + }); + + const response = await request(app) + .get('/api/test') + .set('tailscale-user-login', 'user@example.com') + .set('tailscale-user-name', 'Test User') + .set('tailscale-user-profile-pic', 'https://example.com/pic.jpg') + .set('x-forwarded-proto', 'https') + .set('x-forwarded-for', '100.64.0.1') + .set('x-forwarded-host', 'myhost.tailnet.ts.net'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + userId: 'user@example.com', + authMethod: 'tailscale', + tailscaleUser: { + login: 'user@example.com', + name: 'Test User', + profilePic: 'https://example.com/pic.jpg', + }, + }); + }); + + it('should reject Tailscale headers without proxy headers', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (_req, res) => res.json({ success: true })); + + const response = await request(app) + .get('/api/test') + .set('tailscale-user-login', 'user@example.com') + .set('tailscale-user-name', 'Test User'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid proxy configuration' }); + }); + + it('should reject Tailscale headers from non-localhost', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + // Create a custom request to control remoteAddress + const req = { + headers: { + 'tailscale-user-login': 'user@example.com', + 'tailscale-user-name': 'Test User', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': '100.64.0.1', + 'x-forwarded-host': 'myhost.tailnet.ts.net', + }, + socket: { + remoteAddress: '192.168.1.100', // Non-localhost IP + }, + path: '/test', + } as unknown as AuthenticatedRequest; + + middleware(req, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Invalid request origin' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should handle missing Tailscale login header', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (_req, res) => res.json({ success: true })); + + const response = await request(app) + .get('/api/test') + .set('tailscale-user-name', 'Test User') + .set('x-forwarded-proto', 'https') + .set('x-forwarded-for', '100.64.0.1') + .set('x-forwarded-host', 'myhost.tailnet.ts.net'); + + expect(response.status).toBe(401); + }); + + it('should set tailscale auth info on /api/auth endpoints', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/auth/config', (req: AuthenticatedRequest, res) => { + res.json({ + authMethod: req.authMethod, + userId: req.userId, + tailscaleUser: req.tailscaleUser, + }); + }); + + const response = await request(app) + .get('/api/auth/config') + .set('tailscale-user-login', 'user@example.com') + .set('tailscale-user-name', 'Test User') + .set('x-forwarded-proto', 'https') + .set('x-forwarded-for', '100.64.0.1') + .set('x-forwarded-host', 'myhost.tailnet.ts.net'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + authMethod: 'tailscale', + userId: 'user@example.com', + }); + }); + }); + + describe('Local Bypass Authentication', () => { + it('should allow local requests when allowLocalBypass is true', async () => { + const middleware = createAuthMiddleware({ + allowLocalBypass: true, + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (req: AuthenticatedRequest, res) => { + res.json({ + success: true, + authMethod: req.authMethod, + userId: req.userId, + }); + }); + + // Supertest automatically sets host to 127.0.0.1 for local requests + const response = await request(app).get('/api/test'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + success: true, + authMethod: 'local-bypass', + userId: 'local-user', + }); + }); + + it('should require token for local bypass when localAuthToken is set', async () => { + const middleware = createAuthMiddleware({ + allowLocalBypass: true, + localAuthToken: 'secret-token', + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (_req, res) => res.json({ success: true })); + + // Without token + const response1 = await request(app).get('/api/test'); + expect(response1.status).toBe(401); + + // With wrong token + const response2 = await request(app) + .get('/api/test') + .set('x-vibetunnel-local', 'wrong-token'); + expect(response2.status).toBe(401); + + // With correct token + const response3 = await request(app) + .get('/api/test') + .set('x-vibetunnel-local', 'secret-token'); + expect(response3.status).toBe(200); + }); + + it('should reject requests with forwarded headers even from localhost', async () => { + const middleware = createAuthMiddleware({ + allowLocalBypass: true, + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (_req, res) => res.json({ success: true })); + + const response = await request(app).get('/api/test').set('X-Forwarded-For', '192.168.1.100'); + + expect(response.status).toBe(401); + }); + }); + + describe('Bearer Token Authentication', () => { + it('should authenticate with valid bearer token', async () => { + mockAuthService.verifyToken = vi.fn().mockReturnValue({ valid: true, userId: 'test-user' }); + + const middleware = createAuthMiddleware({ + authService: mockAuthService, + enableSSHKeys: true, + }); + + app.use('/api', middleware); + app.get('/api/test', (req: AuthenticatedRequest, res) => { + res.json({ + success: true, + userId: req.userId, + authMethod: req.authMethod, + }); + }); + + const response = await request(app) + .get('/api/test') + .set('Authorization', 'Bearer valid-token'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + userId: 'test-user', + authMethod: 'ssh-key', + }); + expect(mockAuthService.verifyToken).toHaveBeenCalledWith('valid-token'); + }); + + it('should reject invalid bearer token', async () => { + mockAuthService.verifyToken = vi.fn().mockReturnValue({ valid: false }); + + const middleware = createAuthMiddleware({ + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/test', (_req, res) => res.json({ success: true })); + + const response = await request(app) + .get('/api/test') + .set('Authorization', 'Bearer invalid-token'); + + expect(response.status).toBe(401); + }); + }); + + describe('Security Validations', () => { + it('should skip auth for auth endpoints', async () => { + const middleware = createAuthMiddleware({ + authService: mockAuthService, + }); + + app.use(middleware); + app.post('/api/auth/login', (_req, res) => res.json({ success: true })); + app.post('/auth/login', (_req, res) => res.json({ success: true })); + + const response1 = await request(app).post('/api/auth/login'); + expect(response1.status).toBe(200); + + const response2 = await request(app).post('/auth/login'); + expect(response2.status).toBe(200); + }); + + it('should skip auth for logs endpoint', async () => { + const middleware = createAuthMiddleware({ + authService: mockAuthService, + }); + + app.use(middleware); + app.post('/logs', (_req, res) => res.json({ success: true })); + + const response = await request(app).post('/logs'); + expect(response.status).toBe(200); + }); + + it('should skip auth for push endpoint', async () => { + const middleware = createAuthMiddleware({ + authService: mockAuthService, + }); + + app.use(middleware); + app.post('/push/subscribe', (_req, res) => res.json({ success: true })); + + const response = await request(app).post('/push/subscribe'); + expect(response.status).toBe(200); + }); + + it('should require auth for other endpoints when no auth method succeeds', async () => { + const middleware = createAuthMiddleware({ + authService: mockAuthService, + }); + + app.use('/api', middleware); + app.get('/api/sessions', (_req, res) => res.json({ success: true })); + + const response = await request(app).get('/api/sessions'); + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Authentication required' }); + }); + }); + + describe('IPv6 localhost handling', () => { + it('should accept ::1 as localhost for Tailscale auth', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + const req = { + headers: { + 'tailscale-user-login': 'user@example.com', + 'tailscale-user-name': 'Test User', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': '100.64.0.1', + 'x-forwarded-host': 'myhost.tailnet.ts.net', + }, + socket: { + remoteAddress: '::1', // IPv6 localhost + }, + path: '/test', + } as unknown as AuthenticatedRequest; + + middleware(req, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should accept ::ffff:127.0.0.1 as localhost for Tailscale auth', async () => { + const middleware = createAuthMiddleware({ + allowTailscaleAuth: true, + authService: mockAuthService, + }); + + const req = { + headers: { + 'tailscale-user-login': 'user@example.com', + 'tailscale-user-name': 'Test User', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': '100.64.0.1', + 'x-forwarded-host': 'myhost.tailnet.ts.net', + }, + socket: { + remoteAddress: '::ffff:127.0.0.1', // IPv4-mapped IPv6 + }, + path: '/test', + } as unknown as AuthenticatedRequest; + + middleware(req, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/server/middleware/auth.ts b/web/src/server/middleware/auth.ts index de984be1..ff26cab2 100644 --- a/web/src/server/middleware/auth.ts +++ b/web/src/server/middleware/auth.ts @@ -13,12 +13,18 @@ interface AuthConfig { authService?: AuthService; // Enhanced auth service for JWT tokens allowLocalBypass?: boolean; // Allow localhost connections to bypass auth localAuthToken?: string; // Token for localhost authentication + allowTailscaleAuth?: boolean; // Allow Tailscale identity headers for authentication } export interface AuthenticatedRequest extends Request { userId?: string; - authMethod?: 'ssh-key' | 'password' | 'hq-bearer' | 'no-auth' | 'local-bypass'; + authMethod?: 'ssh-key' | 'password' | 'hq-bearer' | 'no-auth' | 'local-bypass' | 'tailscale'; isHQRequest?: boolean; + tailscaleUser?: { + login: string; + name: string; + profilePic?: string; + }; } // Helper function to check if request is from localhost @@ -47,14 +53,52 @@ function isLocalRequest(req: Request): boolean { return ipIsLocal && noForwardedFor && noRealIP && noForwardedHost && hostIsLocal; } +// Helper function to check if request is from localhost (for reverse proxy scenarios) +function isFromLocalhostAddress(req: Request): boolean { + const remoteAddr = req.socket.remoteAddress; + return remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1'; +} + +// Helper function to check if request has valid Tailscale headers +function getTailscaleUser( + req: Request +): { login: string; name: string; profilePic?: string } | null { + // Tailscale headers don't have 'x-' prefix + const login = req.headers['tailscale-user-login'] as string; + const name = req.headers['tailscale-user-name'] as string; + const profilePic = req.headers['tailscale-user-profile-pic'] as string; + + // Must have at least login to be valid + if (!login) { + return null; + } + + return { + login, + name: name || login, // Fallback to login if name not provided + profilePic, + }; +} + export function createAuthMiddleware(config: AuthConfig) { return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - // Skip auth for auth endpoints, client logging, and push notifications + // Skip auth for auth endpoints, client logging, push notifications, and Tailscale status if ( req.path.startsWith('/auth') || req.path.startsWith('/logs') || + req.path === '/sessions/tailscale/status' || req.path.startsWith('/push') ) { + // Special case: If Tailscale auth is enabled and we have valid headers, + // set the auth info even for /auth endpoints so the client knows we're authenticated + if (config.allowTailscaleAuth && req.path.startsWith('/auth')) { + const tailscaleUser = getTailscaleUser(req); + if (tailscaleUser) { + req.authMethod = 'tailscale'; + req.userId = tailscaleUser.login; + req.tailscaleUser = tailscaleUser; + } + } return next(); } @@ -65,6 +109,43 @@ export function createAuthMiddleware(config: AuthConfig) { return next(); } + // Check for Tailscale authentication if enabled + if (config.allowTailscaleAuth) { + const tailscaleUser = getTailscaleUser(req); + if (tailscaleUser) { + // Security check: Ensure request is from localhost (Tailscale Serve proxy) + if (!isFromLocalhostAddress(req)) { + logger.warn( + `Tailscale headers present but request not from localhost: ${req.socket.remoteAddress}` + ); + return res.status(401).json({ error: 'Invalid request origin' }); + } + + // Additional check: Verify proxy headers exist + const hasProxyHeaders = !!( + req.headers['x-forwarded-proto'] && + req.headers['x-forwarded-for'] && + req.headers['x-forwarded-host'] + ); + + if (!hasProxyHeaders) { + logger.warn('Tailscale headers present but missing proxy headers'); + return res.status(401).json({ error: 'Invalid proxy configuration' }); + } + + // Log Tailscale authentication + logger.info(`Tailscale authentication successful for user: ${tailscaleUser.login}`); + logger.info( + `User details - Name: ${tailscaleUser.name}, Has profile pic: ${!!tailscaleUser.profilePic}` + ); + + req.authMethod = 'tailscale'; + req.userId = tailscaleUser.login; + req.tailscaleUser = tailscaleUser; + return next(); + } + } + // Check for local bypass if enabled if (config.allowLocalBypass && isLocalRequest(req)) { // If a local auth token is configured, check for it diff --git a/web/src/server/routes/auth.ts b/web/src/server/routes/auth.ts index 2f27e1f0..d784ee4c 100644 --- a/web/src/server/routes/auth.ts +++ b/web/src/server/routes/auth.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { promisify } from 'util'; +import type { AuthenticatedRequest } from '../middleware/auth.js'; import type { AuthService } from '../services/auth-service.js'; interface AuthRoutesConfig { @@ -169,13 +170,35 @@ export function createAuthRoutes(config: AuthRoutesConfig): Router { * Get authentication configuration * GET /api/auth/config */ - router.get('/config', (_req, res) => { + router.get('/config', (req: AuthenticatedRequest, res) => { try { - res.json({ + interface AuthConfigResponse { + enableSSHKeys: boolean; + disallowUserPassword: boolean; + noAuth: boolean; + tailscaleAuth?: boolean; + authenticatedUser?: string; + tailscaleUser?: { + login: string; + name: string; + profilePic?: string; + }; + } + + const response: AuthConfigResponse = { enableSSHKeys: config.enableSSHKeys || false, disallowUserPassword: config.disallowUserPassword || false, noAuth: config.noAuth || false, - }); + }; + + // If user is authenticated via Tailscale, indicate this + if (req.authMethod === 'tailscale' && req.userId) { + response.tailscaleAuth = true; + response.authenticatedUser = req.userId; + response.tailscaleUser = req.tailscaleUser; + } + + res.json(response); } catch (error) { console.error('Error getting auth config:', error); res.status(500).json({ error: 'Failed to get auth config' }); diff --git a/web/src/server/routes/sessions.ts b/web/src/server/routes/sessions.ts index ec57b487..7d3e807f 100644 --- a/web/src/server/routes/sessions.ts +++ b/web/src/server/routes/sessions.ts @@ -10,6 +10,7 @@ import { PtyError, type PtyManager } from '../pty/index.js'; import type { ActivityMonitor } from '../services/activity-monitor.js'; import type { RemoteRegistry } from '../services/remote-registry.js'; import type { StreamWatcher } from '../services/stream-watcher.js'; +import { tailscaleServeService } from '../services/tailscale-serve-service.js'; import type { TerminalManager } from '../services/terminal-manager.js'; import { detectGitInfo } from '../utils/git-info.js'; import { getDetailedGitStatus } from '../utils/git-status.js'; @@ -69,6 +70,18 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router { } }); + // Tailscale Serve status endpoint + router.get('/sessions/tailscale/status', async (_req, res) => { + logger.debug('[GET /sessions/tailscale/status] Getting Tailscale Serve status'); + try { + const status = await tailscaleServeService.getStatus(); + res.json(status); + } catch (error) { + logger.error('Failed to get Tailscale Serve status:', error); + res.status(500).json({ error: 'Failed to get Tailscale Serve status' }); + } + }); + // List all sessions (aggregate local + remote in HQ mode) router.get('/sessions', async (_req, res) => { logger.debug('[GET /sessions] Listing all sessions'); diff --git a/web/src/server/server.ts b/web/src/server/server.ts index d4678b2a..43135a5b 100644 --- a/web/src/server/server.ts +++ b/web/src/server/server.ts @@ -43,6 +43,7 @@ import { PushNotificationService } from './services/push-notification-service.js import { RemoteRegistry } from './services/remote-registry.js'; import { SessionMonitor } from './services/session-monitor.js'; import { StreamWatcher } from './services/stream-watcher.js'; +import { tailscaleServeService } from './services/tailscale-serve-service.js'; import { TerminalManager } from './services/terminal-manager.js'; import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js'; import { VapidManager } from './utils/vapid-manager.js'; @@ -93,6 +94,8 @@ interface Config { // Local bypass configuration allowLocalBypass: boolean; localAuthToken: string | null; + // Tailscale Serve integration (manages auth and proxy) + enableTailscaleServe: boolean; // HQ auth bypass for testing noHqAuth: boolean; // mDNS advertisement @@ -116,6 +119,7 @@ Options: --no-auth Disable authentication (auto-login as current user) --allow-local-bypass Allow localhost connections to bypass authentication --local-auth-token Token for localhost authentication bypass + --enable-tailscale-serve Enable Tailscale Serve integration (auto-manages proxy and auth) --debug Enable debug logging Push Notification Options: @@ -186,6 +190,8 @@ function parseArgs(): Config { // Local bypass configuration allowLocalBypass: false, localAuthToken: null as string | null, + // Tailscale Serve integration (manages auth and proxy) + enableTailscaleServe: false, // HQ auth bypass for testing noHqAuth: false, // mDNS advertisement @@ -251,6 +257,8 @@ function parseArgs(): Config { } else if (args[i] === '--local-auth-token' && i + 1 < args.length) { config.localAuthToken = args[i + 1]; i++; // Skip the token value in next iteration + } else if (args[i] === '--enable-tailscale-serve') { + config.enableTailscaleServe = true; } else if (args[i] === '--no-hq-auth') { config.noHqAuth = true; } else if (args[i] === '--no-mdns') { @@ -318,6 +326,14 @@ function validateConfig(config: ReturnType) { process.exit(1); } + // Validate Tailscale configuration + if (config.enableTailscaleServe && config.bind === '0.0.0.0') { + logger.error('Security Error: Cannot bind to 0.0.0.0 when using Tailscale Serve'); + logger.error('Tailscale Serve requires binding to localhost (127.0.0.1)'); + logger.error('Use --bind 127.0.0.1 or disable Tailscale Serve'); + process.exit(1); + } + // Can't be both HQ mode and register with HQ if (config.isHQMode && config.hqUrl) { logger.error('Cannot use --hq and --hq-url together'); @@ -565,6 +581,7 @@ export async function createApp(): Promise { authService, // Add enhanced auth service for JWT tokens allowLocalBypass: config.allowLocalBypass, localAuthToken: config.localAuthToken || undefined, + allowTailscaleAuth: config.enableTailscaleServe, }); // Serve static files with .html extension handling and caching headers @@ -828,7 +845,11 @@ export async function createApp(): Promise { logger.debug('Connected Claude turn notifications to PTY manager'); } - // Mount authentication routes (no auth required) + // Apply auth middleware to all API routes (including auth routes for Tailscale header detection) + app.use('/api', authMiddleware); + logger.debug('Applied authentication middleware to /api routes'); + + // Mount authentication routes (auth middleware will skip these but still check Tailscale headers) app.use( '/api/auth', createAuthRoutes({ @@ -840,10 +861,6 @@ export async function createApp(): Promise { ); logger.debug('Mounted authentication routes'); - // Apply auth middleware to all API routes (except auth routes which are handled above) - app.use('/api', authMiddleware); - logger.debug('Applied authentication middleware to /api routes'); - // Mount routes app.use( '/api', @@ -1187,7 +1204,9 @@ export async function createApp(): Promise { } }); - const bindAddress = config.bind || '0.0.0.0'; + // Regular TCP mode + logger.log(`Starting server on port ${requestedPort}`); + const bindAddress = config.bind || (config.enableTailscaleServe ? '127.0.0.1' : '0.0.0.0'); server.listen(requestedPort, bindAddress, () => { const address = server.address(); const actualPort = @@ -1217,6 +1236,43 @@ export async function createApp(): Promise { } } + // Start Tailscale Serve if requested + if (config.enableTailscaleServe) { + logger.log(chalk.blue('Starting Tailscale Serve integration...')); + + tailscaleServeService + .start(actualPort) + .then(() => { + logger.log(chalk.green('Tailscale Serve: ENABLED')); + logger.log( + chalk.gray('Users will be auto-authenticated via Tailscale identity headers') + ); + logger.log( + chalk.gray( + `Access via HTTPS on your Tailscale hostname (e.g., https://hostname.tailnet.ts.net)` + ) + ); + }) + .catch((error) => { + logger.error(chalk.red('Failed to start Tailscale Serve:'), error.message); + logger.warn( + chalk.yellow('VibeTunnel will continue running, but Tailscale Serve is not available') + ); + logger.log(chalk.blue('You can manually configure Tailscale Serve with:')); + logger.log(chalk.gray(` tailscale serve ${actualPort}`)); + }); + } + + // Log local bypass status + if (config.allowLocalBypass) { + logger.log(chalk.yellow('Local Bypass: ENABLED')); + if (config.localAuthToken) { + logger.log(chalk.gray('Local connections require auth token')); + } else { + logger.log(chalk.gray('Local connections bypass authentication without token')); + } + } + // Initialize HQ client now that we know the actual port if ( config.hqUrl && @@ -1414,6 +1470,13 @@ export async function startVibeTunnelServer() { logger.debug('Stopped mDNS advertisement'); } + // Stop Tailscale Serve if it was started + if (config.enableTailscaleServe && tailscaleServeService.isRunning()) { + logger.log('Stopping Tailscale Serve...'); + await tailscaleServeService.stop(); + logger.debug('Stopped Tailscale Serve service'); + } + // Stop control directory watcher if (controlDirWatcher) { controlDirWatcher.stop(); diff --git a/web/src/server/services/tailscale-serve-service.ts b/web/src/server/services/tailscale-serve-service.ts new file mode 100644 index 00000000..e633e269 --- /dev/null +++ b/web/src/server/services/tailscale-serve-service.ts @@ -0,0 +1,334 @@ +import { type ChildProcess, spawn } from 'child_process'; +import { createLogger } from '../utils/logger.js'; + +const logger = createLogger('tailscale-serve'); + +export interface TailscaleServeService { + start(port: number): Promise; + stop(): Promise; + isRunning(): boolean; + getStatus(): Promise; +} + +export interface TailscaleServeStatus { + isRunning: boolean; + port?: number; + error?: string; + lastError?: string; + startTime?: Date; +} + +/** + * Service to manage Tailscale Serve as a background process + */ +export class TailscaleServeServiceImpl implements TailscaleServeService { + private serveProcess: ChildProcess | null = null; + private currentPort: number | null = null; + private isStarting = false; + private tailscaleExecutable = 'tailscale'; // Default to PATH lookup + private lastError: string | undefined; + private startTime: Date | undefined; + + async start(port: number): Promise { + if (this.isStarting) { + throw new Error('Tailscale Serve is already starting'); + } + + if (this.serveProcess) { + logger.info('Tailscale Serve is already running, stopping first...'); + await this.stop(); + } + + this.isStarting = true; + this.lastError = undefined; // Clear previous errors + + try { + // Check if tailscale command is available + await this.checkTailscaleAvailable(); + + // First, reset any existing serve configuration + try { + logger.debug('Resetting Tailscale Serve configuration...'); + const resetProcess = spawn(this.tailscaleExecutable, ['serve', 'reset'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + await new Promise((resolve) => { + resetProcess.on('exit', () => resolve()); + resetProcess.on('error', () => resolve()); // Continue even if reset fails + setTimeout(resolve, 1000); // Timeout after 1 second + }); + } catch (_error) { + logger.debug('Failed to reset serve config (this is normal if none exists)'); + } + + // TCP port: tailscale serve port + const args = ['serve', port.toString()]; + logger.info(`Starting Tailscale Serve on port ${port}`); + logger.debug(`Command: ${this.tailscaleExecutable} ${args.join(' ')}`); + this.currentPort = port; + + // Start the serve process + this.serveProcess = spawn(this.tailscaleExecutable, args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, // Keep it attached to our process + }); + + // Handle process events + this.serveProcess.on('error', (error) => { + logger.error(`Tailscale Serve process error: ${error.message}`); + this.lastError = error.message; + this.cleanup(); + }); + + this.serveProcess.on('exit', (code, signal) => { + logger.info(`Tailscale Serve process exited with code ${code}, signal ${signal}`); + if (code !== 0) { + this.lastError = `Process exited with code ${code}`; + } + this.cleanup(); + }); + + // Log stdout/stderr + if (this.serveProcess.stdout) { + this.serveProcess.stdout.on('data', (data) => { + logger.debug(`Tailscale Serve stdout: ${data.toString().trim()}`); + }); + } + + if (this.serveProcess.stderr) { + this.serveProcess.stderr.on('data', (data) => { + const stderr = data.toString().trim(); + logger.debug(`Tailscale Serve stderr: ${stderr}`); + // Capture common error patterns + if (stderr.includes('error') || stderr.includes('failed')) { + this.lastError = stderr; + } + }); + } + + // Wait a moment to see if it starts successfully + await new Promise((resolve, reject) => { + let settled = false; + + const settlePromise = (isSuccess: boolean, error?: Error | string) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + + if (isSuccess) { + logger.info('Tailscale Serve started successfully'); + this.startTime = new Date(); + resolve(); + } else { + const errorMessage = + error instanceof Error ? error.message : error || 'Tailscale Serve failed to start'; + this.lastError = errorMessage; + reject(new Error(errorMessage)); + } + }; + + const timeout = setTimeout(() => { + if (this.serveProcess && !this.serveProcess.killed) { + settlePromise(true); + } else { + settlePromise(false, this.lastError); + } + }, 3000); // Wait 3 seconds + + if (this.serveProcess) { + this.serveProcess.once('error', (error) => { + settlePromise(false, error); + }); + + this.serveProcess.once('exit', (code) => { + // Exit code 0 during startup might indicate success for some commands + // But for 'tailscale serve', it usually means it couldn't start + if (code === 0) { + settlePromise( + false, + `Tailscale Serve exited immediately with code 0 - likely already configured or invalid state` + ); + } else { + settlePromise(false, `Tailscale Serve exited unexpectedly with code ${code}`); + } + }); + } + }); + } catch (error) { + this.lastError = error instanceof Error ? error.message : String(error); + this.cleanup(); + throw error; + } finally { + this.isStarting = false; + } + } + + async stop(): Promise { + // First try to remove the serve configuration + try { + logger.debug('Removing Tailscale Serve configuration...'); + + // Use 'reset' to completely clear all serve configuration + const resetProcess = spawn(this.tailscaleExecutable, ['serve', 'reset'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + await new Promise((resolve) => { + resetProcess.on('exit', (code) => { + if (code === 0) { + logger.debug('Tailscale Serve configuration reset successfully'); + } + resolve(); + }); + resetProcess.on('error', () => resolve()); + setTimeout(resolve, 2000); // Timeout after 2 seconds + }); + } catch (_error) { + logger.debug('Failed to reset serve config during stop'); + } + + if (!this.serveProcess) { + logger.debug('No Tailscale Serve process to stop'); + return; + } + + logger.info('Stopping Tailscale Serve process...'); + + return new Promise((resolve) => { + if (!this.serveProcess) { + resolve(); + return; + } + + const cleanup = () => { + this.cleanup(); + resolve(); + }; + + // Set a timeout to force kill if graceful shutdown fails + const forceKillTimeout = setTimeout(() => { + if (this.serveProcess && !this.serveProcess.killed) { + logger.warn('Force killing Tailscale Serve process'); + this.serveProcess.kill('SIGKILL'); + } + cleanup(); + }, 5000); + + this.serveProcess.once('exit', () => { + clearTimeout(forceKillTimeout); + cleanup(); + }); + + // Try graceful shutdown first + this.serveProcess.kill('SIGTERM'); + }); + } + + isRunning(): boolean { + return this.serveProcess !== null && !this.serveProcess.killed; + } + + async getStatus(): Promise { + const isRunning = this.isRunning(); + + // Debug mode: simulate errors based on environment variable + if (process.env.VIBETUNNEL_TAILSCALE_ERROR) { + return { + isRunning: false, + lastError: process.env.VIBETUNNEL_TAILSCALE_ERROR, + }; + } + + return { + isRunning, + port: isRunning ? (this.currentPort ?? undefined) : undefined, + lastError: this.lastError, + startTime: this.startTime, + }; + } + + private cleanup(): void { + // Kill the process if it's still running + if (this.serveProcess && !this.serveProcess.killed) { + logger.debug('Terminating orphaned Tailscale Serve process'); + try { + this.serveProcess.kill('SIGTERM'); + // Give it a moment to terminate gracefully + setTimeout(() => { + if (this.serveProcess && !this.serveProcess.killed) { + logger.warn('Force killing Tailscale Serve process'); + this.serveProcess.kill('SIGKILL'); + } + }, 1000); + } catch (error) { + logger.error('Failed to kill Tailscale Serve process:', error); + } + } + + this.serveProcess = null; + this.currentPort = null; + this.isStarting = false; + this.startTime = undefined; + // Keep lastError for debugging + } + + private async checkTailscaleAvailable(): Promise { + const fs = await import('fs/promises'); + + // Platform-specific paths to check + let tailscalePaths: string[] = []; + + if (process.platform === 'darwin') { + // macOS paths + tailscalePaths = [ + '/Applications/Tailscale.app/Contents/MacOS/Tailscale', + '/usr/local/bin/tailscale', + '/opt/homebrew/bin/tailscale', + ]; + } else if (process.platform === 'linux') { + // Linux paths + tailscalePaths = [ + '/usr/bin/tailscale', + '/usr/local/bin/tailscale', + '/opt/tailscale/bin/tailscale', + '/snap/bin/tailscale', + ]; + } + + // Check platform-specific paths first + for (const path of tailscalePaths) { + try { + await fs.access(path, fs.constants.X_OK); + this.tailscaleExecutable = path; + logger.debug(`Found Tailscale at: ${path}`); + return; + } catch { + // Continue checking other paths + } + } + + // Fallback to checking PATH + return new Promise((resolve, reject) => { + const checkProcess = spawn('which', ['tailscale'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + checkProcess.on('exit', (code) => { + if (code === 0) { + // Keep default 'tailscale' which will use PATH + resolve(); + } else { + reject(new Error('Tailscale command not found. Please install Tailscale first.')); + } + }); + + checkProcess.on('error', (error) => { + reject(new Error(`Failed to check Tailscale availability: ${error.message}`)); + }); + }); + } +} + +// Singleton instance +export const tailscaleServeService = new TailscaleServeServiceImpl();