vibetunnel/mac/VibeTunnel/Core/Services/DevServerManager.swift
Lachlan Donald 745f5090bb
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>
2025-07-30 02:30:10 +02:00

224 lines
8 KiB
Swift

import Foundation
import OSLog
/// Manages development server configuration and validation
@MainActor
final class DevServerManager: ObservableObject {
static let shared = DevServerManager()
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "DevServerManager")
/// Validates a development server path
func validate(path: String) -> DevServerValidation {
guard !path.isEmpty else {
return .notValidated
}
// Expand tilde in path
let expandedPath = NSString(string: path).expandingTildeInPath
let projectURL = URL(fileURLWithPath: expandedPath)
// Check if directory exists
guard FileManager.default.fileExists(atPath: expandedPath) else {
return .invalid("Directory does not exist")
}
// Check if package.json exists
let packageJsonPath = projectURL.appendingPathComponent("package.json").path
guard FileManager.default.fileExists(atPath: packageJsonPath) else {
return .invalid("No package.json found in directory")
}
// Check if pnpm is installed
guard isPnpmInstalled() else {
return .invalid("pnpm is not installed. Install it with: npm install -g pnpm")
}
// Check if dev script exists
guard hasDevScript(at: packageJsonPath) else {
return .invalid("No 'dev' script found in package.json")
}
logger.info("Dev server path validated successfully: \(expandedPath)")
return .valid
}
/// Checks if pnpm is installed on the system
private func isPnpmInstalled() -> Bool {
// Common locations where pnpm might be installed
let commonPaths = [
"/usr/local/bin/pnpm",
"/opt/homebrew/bin/pnpm",
"/usr/bin/pnpm",
NSString("~/Library/pnpm/pnpm").expandingTildeInPath,
NSString("~/.local/share/pnpm/pnpm").expandingTildeInPath,
NSString("~/Library/Caches/fnm_multishells/*/bin/pnpm").expandingTildeInPath
]
// Check common paths first
for path in commonPaths where FileManager.default.isExecutableFile(atPath: path) {
logger.debug("Found pnpm at: \(path)")
return true
}
// Try using the shell to find pnpm with full PATH
let pnpmCheck = Process()
pnpmCheck.executableURL = URL(fileURLWithPath: "/bin/zsh")
pnpmCheck.arguments = ["-l", "-c", "command -v pnpm"]
pnpmCheck.standardOutput = Pipe()
pnpmCheck.standardError = Pipe()
// Set up environment with common PATH additions
var environment = ProcessInfo.processInfo.environment
let homePath = NSHomeDirectory()
let additionalPaths = [
"\(homePath)/Library/pnpm",
"\(homePath)/.local/share/pnpm",
"/usr/local/bin",
"/opt/homebrew/bin"
].joined(separator: ":")
if let existingPath = environment["PATH"] {
environment["PATH"] = "\(existingPath):\(additionalPaths)"
} else {
environment["PATH"] = additionalPaths
}
pnpmCheck.environment = environment
do {
try pnpmCheck.run()
pnpmCheck.waitUntilExit()
if pnpmCheck.terminationStatus == 0 {
// Try to read the output to log where pnpm was found
if let pipe = pnpmCheck.standardOutput as? Pipe {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
{
logger.debug("Found pnpm via shell at: \(output)")
}
}
return true
}
} catch {
logger.error("Failed to check for pnpm: \(error.localizedDescription)")
}
return false
}
/// Checks if package.json has a dev script
private func hasDevScript(at packageJsonPath: String) -> Bool {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: packageJsonPath)),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let scripts = json["scripts"] as? [String: String]
else {
return false
}
return scripts["dev"] != nil
}
/// Gets the expanded path for a given path string
func expandedPath(for path: String) -> String {
NSString(string: path).expandingTildeInPath
}
/// Finds the path to pnpm executable
func findPnpmPath() -> String? {
// Common locations where pnpm might be installed
let commonPaths = [
"/usr/local/bin/pnpm",
"/opt/homebrew/bin/pnpm",
"/usr/bin/pnpm",
NSString("~/Library/pnpm/pnpm").expandingTildeInPath,
NSString("~/.local/share/pnpm/pnpm").expandingTildeInPath
]
// Check common paths first
for path in commonPaths where FileManager.default.isExecutableFile(atPath: path) {
return path
}
// Try to find via shell
let findPnpm = Process()
findPnpm.executableURL = URL(fileURLWithPath: "/bin/zsh")
findPnpm.arguments = ["-l", "-c", "command -v pnpm"]
findPnpm.standardOutput = Pipe()
findPnpm.standardError = Pipe()
// Set up environment with common PATH additions
var environment = ProcessInfo.processInfo.environment
let homePath = NSHomeDirectory()
let additionalPaths = [
"\(homePath)/Library/pnpm",
"\(homePath)/.local/share/pnpm",
"/usr/local/bin",
"/opt/homebrew/bin"
].joined(separator: ":")
if let existingPath = environment["PATH"] {
environment["PATH"] = "\(existingPath):\(additionalPaths)"
} else {
environment["PATH"] = additionalPaths
}
findPnpm.environment = environment
do {
try findPnpm.run()
findPnpm.waitUntilExit()
if findPnpm.terminationStatus == 0,
let pipe = findPnpm.standardOutput as? Pipe
{
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!output.isEmpty
{
return output
}
}
} catch {
logger.error("Failed to find pnpm path: \(error.localizedDescription)")
}
return nil
}
/// Builds the command arguments for running the dev server
func buildDevServerArguments(port: String, bindAddress: String, authMode: String, localToken: String?) -> [String] {
var args = ["run", "dev", "--"]
// Add the same arguments as the production server
args.append(contentsOf: ["--port", port, "--bind", bindAddress])
// Add authentication flags based on configuration
switch authMode {
case "none":
args.append("--no-auth")
case "ssh":
args.append(contentsOf: ["--enable-ssh-keys", "--disallow-user-password"])
case "both":
args.append("--enable-ssh-keys")
default:
// OS authentication is the default
break
}
// Add local bypass authentication for the Mac app
if authMode != "none", let token = localToken {
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
}
}