feat: add Tailscale Serve integration with automatic authentication (#472)

* feat: add secure Tailscale Serve integration support

- Add --enable-tailscale-serve flag to bind server to localhost
- Implement Tailscale identity header authentication
- Add security validations for localhost origin and proxy headers
- Create TailscaleServeService to manage tailscale serve process
- Fix dev script to properly pass arguments through pnpm
- Add comprehensive auth middleware tests for all auth methods
- Ensure secure integration with Tailscale's reverse proxy

* refactor: use isFromLocalhostAddress helper for Tailscale auth

- Extract localhost checking logic into dedicated helper function
- Makes the code clearer and addresses review feedback
- Maintains the same security checks for Tailscale authentication

* feat(web): Add Tailscale Serve integration support

- Add TailscaleServeService to manage background tailscale serve process
- Add --enable-tailscale-serve and --use-tailscale-serve flags
- Force localhost binding when Tailscale Serve is enabled
- Enhance auth middleware to support Tailscale identity headers
- Add isFromLocalhostAddress helper for secure localhost validation
- Fix dev script to properly pass CLI arguments through pnpm
- Add comprehensive auth middleware tests (17 tests)
- Use 'tailscale serve reset' for thorough cleanup

The server now automatically manages the Tailscale Serve proxy process,
providing secure HTTPS access through Tailscale networks without manual
configuration.

* feat(mac): Add Tailscale Serve toggle in Remote Access settings

- Add 'Enable Tailscale Serve Integration' toggle in RemoteAccessSettingsView
- Pass --use-tailscale-serve flag from both BunServer and DevServerManager
- Show HTTPS URL when Tailscale Serve is enabled, HTTP when disabled
- Fix URL copy bug in ServerInfoSection for Tailscale addresses
- Update authentication documentation with new integration mode
- Server automatically restarts when toggle is changed

The macOS app now provides a user-friendly toggle to enable secure
Tailscale Serve integration without manual configuration.

* fix(security): Remove dangerous --allow-tailscale-auth flag

- Remove --allow-tailscale-auth flag that allowed header spoofing
- Remove --use-tailscale-serve alias for consistency
- Keep only --enable-tailscale-serve which safely manages everything
- Update all references in server.ts to use enableTailscaleServe
- Update macOS app to use --enable-tailscale-serve flag
- Update documentation to remove manual setup mode

The --allow-tailscale-auth flag was dangerous because it allowed users to
enable Tailscale header authentication while binding to network interfaces,
which would allow anyone on the network to spoof the Tailscale headers.

Now there's only one safe way to use Tailscale integration: --enable-tailscale-serve,
which forces localhost binding and manages the proxy automatically.

* fix: address PR feedback from Peter and Cursor

- Fix Promise hang bug in TailscaleServeService when process exits with code 0
- Move tailscaleServeEnabled string to AppConstants.UserDefaultsKeys
- Create TailscaleURLHelper for URL construction logic
- Add Linux support to TailscaleServeService with common Tailscale paths
- Update all references to use centralized constants
- Fix code formatting issues

* feat: Add Tailscale Serve status monitoring and error visibility

* fix: Correct pass-through argument logic for boolean flags and duplicates

- Track processed argument indices instead of checking if arg already exists in serverArgs
- Add set of known boolean flags that don't take values
- Allow duplicate arguments to be passed through
- Only treat non-dash arguments as values for non-boolean flags

This fixes issues where:
1. Boolean flags like --verbose were incorrectly consuming the next argument
2. Duplicate flags couldn't be passed through to the server

* fix: Resolve promise hanging and orphaned processes in Tailscale serve

- Add settled flag to prevent multiple promise resolutions
- Handle exit code 0 as a failure case during startup
- Properly terminate child process in cleanup method
- Add timeout for graceful shutdown before force killing

This fixes:
1. Promise hanging when tailscale serve exits with code 0
2. Orphaned processes when startup fails or cleanup is called

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Lachlan Donald 2025-07-30 10:30:10 +10:00 committed by GitHub
parent dbba6127df
commit 745f5090bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1513 additions and 60 deletions

View file

@ -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 <number> 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

View file

@ -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"

View file

@ -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

View file

@ -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)"
}
}
}

View file

@ -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"

View file

@ -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(

View file

@ -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
)

View file

@ -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
}
}

View file

@ -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?
}

View file

@ -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
)
}

View file

@ -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)

View file

@ -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)"

View file

@ -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(

View file

@ -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)."
}
}
}

View file

@ -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
)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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' });

View file

@ -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();

View file

@ -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);

View file

@ -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();
});
});
});

View file

@ -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

View file

@ -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' });

View file

@ -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');

View file

@ -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> 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<typeof parseArgs>) {
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<AppInstance> {
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<AppInstance> {
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<AppInstance> {
);
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<AppInstance> {
}
});
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<AppInstance> {
}
}
// 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();

View file

@ -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<void>;
stop(): Promise<void>;
isRunning(): boolean;
getStatus(): Promise<TailscaleServeStatus>;
}
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<void> {
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<void>((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<void>((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<void> {
// 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<void>((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<void>((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<TailscaleServeStatus> {
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<void> {
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<void>((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();