mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
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:
parent
dbba6127df
commit
745f5090bb
28 changed files with 1513 additions and 60 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
36
mac/VibeTunnel/Core/Helpers/TailscaleURLHelper.swift
Normal file
36
mac/VibeTunnel/Core/Helpers/TailscaleURLHelper.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
mac/VibeTunnel/Core/Services/TailscaleServeStatusService.swift
Normal file
123
mac/VibeTunnel/Core/Services/TailscaleServeStatusService.swift
Normal 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?
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
429
web/src/server/middleware/auth.test.ts
Normal file
429
web/src/server/middleware/auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
334
web/src/server/services/tailscale-serve-service.ts
Normal file
334
web/src/server/services/tailscale-serve-service.ts
Normal 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();
|
||||
Loading…
Reference in a new issue