mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-29 05:39:31 +00:00
* 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>
281 lines
9.8 KiB
Swift
281 lines
9.8 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Server Info Header
|
|
|
|
/// Header section of the menu showing server status and connection info.
|
|
///
|
|
/// Displays the VibeTunnel logo, server running status, and available
|
|
/// connection addresses including local, ngrok, and Tailscale endpoints.
|
|
struct ServerInfoHeader: View {
|
|
@Environment(ServerManager.self)
|
|
var serverManager
|
|
@Environment(NgrokService.self)
|
|
var ngrokService
|
|
@Environment(TailscaleService.self)
|
|
var tailscaleService
|
|
@Environment(\.colorScheme)
|
|
private var colorScheme
|
|
|
|
private var appDisplayName: String {
|
|
let (debugMode, useDevServer) = AppConstants.getDevelopmentStatus()
|
|
|
|
var name = debugMode ? "VibeTunnel Debug" : "VibeTunnel"
|
|
if useDevServer && serverManager.isRunning {
|
|
name += " Dev Server"
|
|
}
|
|
return name
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Title and status
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
|
.resizable()
|
|
.frame(width: 24, height: 24)
|
|
.cornerRadius(4)
|
|
.padding(.leading, -5) // Align with small icons below
|
|
|
|
Text(appDisplayName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
ServerStatusBadge(
|
|
isRunning: serverManager.isRunning
|
|
) {
|
|
Task {
|
|
await serverManager.restart()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Server address
|
|
if serverManager.isRunning {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ServerAddressRow()
|
|
|
|
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
|
|
ServerAddressRow(
|
|
icon: "network",
|
|
label: "ngrok:",
|
|
address: publicURL,
|
|
url: URL(string: publicURL)
|
|
)
|
|
}
|
|
|
|
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
|
|
let isTailscaleServeEnabled = UserDefaults.standard
|
|
.bool(forKey: AppConstants.UserDefaultsKeys.tailscaleServeEnabled)
|
|
ServerAddressRow(
|
|
icon: "shield",
|
|
label: "Tailscale:",
|
|
address: TailscaleURLHelper.displayAddress(
|
|
hostname: hostname,
|
|
port: serverManager.port,
|
|
isTailscaleServeEnabled: isTailscaleServeEnabled
|
|
),
|
|
url: TailscaleURLHelper.constructURL(
|
|
hostname: hostname,
|
|
port: serverManager.port,
|
|
isTailscaleServeEnabled: isTailscaleServeEnabled
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Displays a clickable server address with an icon and label.
|
|
///
|
|
/// Shows connection endpoints that can be clicked to open in the browser,
|
|
/// with support for local addresses, ngrok tunnels, and Tailscale connections.
|
|
struct ServerAddressRow: View {
|
|
let icon: String
|
|
let label: String
|
|
let address: String
|
|
let url: URL?
|
|
|
|
@Environment(ServerManager.self)
|
|
var serverManager
|
|
@Environment(\.colorScheme)
|
|
private var colorScheme
|
|
@State private var isHovered = false
|
|
@State private var showCopiedFeedback = false
|
|
|
|
init(
|
|
icon: String = "server.rack",
|
|
label: String = "Local:",
|
|
address: String? = nil,
|
|
url: URL? = nil
|
|
) {
|
|
self.icon = icon
|
|
self.label = label
|
|
self.address = address ?? ""
|
|
self.url = url
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
|
|
.frame(width: 14, alignment: .center)
|
|
Text(label)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
Button(action: {
|
|
if let providedUrl = url {
|
|
NSWorkspace.shared.open(providedUrl)
|
|
} else if computedAddress.starts(with: "127.0.0.1:") {
|
|
// For localhost, use DashboardURLBuilder
|
|
if let dashboardURL = DashboardURLBuilder.dashboardURL(port: serverManager.port) {
|
|
NSWorkspace.shared.open(dashboardURL)
|
|
}
|
|
} else if let url = URL(string: "http://\(computedAddress)") {
|
|
// For other addresses (network IP, etc.), construct URL directly
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}, label: {
|
|
Text(computedAddress)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
|
|
.underline(isHovered)
|
|
})
|
|
.buttonStyle(.plain)
|
|
.pointingHandCursor()
|
|
|
|
// Copy button - always present but opacity changes on hover
|
|
Button(action: {
|
|
copyToClipboard()
|
|
}, label: {
|
|
Image(systemName: showCopiedFeedback ? "checkmark.circle.fill" : "doc.on.doc")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
|
|
})
|
|
.buttonStyle(.plain)
|
|
.pointingHandCursor()
|
|
.help(showCopiedFeedback ? "Copied!" : "Copy to clipboard")
|
|
.opacity(isHovered ? 1.0 : 0.0)
|
|
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
|
}
|
|
.onHover { hovering in
|
|
isHovered = hovering
|
|
}
|
|
}
|
|
|
|
private var computedAddress: String {
|
|
if !address.isEmpty {
|
|
return address
|
|
}
|
|
|
|
// Default behavior for local server
|
|
let bindAddress = serverManager.bindAddress
|
|
if bindAddress == "127.0.0.1" {
|
|
return "127.0.0.1:\(serverManager.port)"
|
|
} else if let localIP = NetworkUtility.getLocalIPAddress() {
|
|
return "\(localIP):\(serverManager.port)"
|
|
} else {
|
|
return "0.0.0.0:\(serverManager.port)"
|
|
}
|
|
}
|
|
|
|
private var urlToCopy: String {
|
|
// If we have a full URL, return it as-is
|
|
if let providedUrl = url {
|
|
return providedUrl.absoluteString
|
|
}
|
|
|
|
// For local addresses, build the full URL
|
|
if computedAddress.starts(with: "127.0.0.1:") {
|
|
return "http://\(computedAddress)"
|
|
} else {
|
|
return "http://\(computedAddress)"
|
|
}
|
|
}
|
|
|
|
private func copyToClipboard() {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(urlToCopy, forType: .string)
|
|
|
|
// Show feedback
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
showCopiedFeedback = true
|
|
}
|
|
|
|
// Hide feedback after 2 seconds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
showCopiedFeedback = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Visual indicator for server running status.
|
|
///
|
|
/// Shows a colored badge with status text indicating whether
|
|
/// the VibeTunnel server is currently running or stopped.
|
|
/// When stopped, the badge is clickable to restart the server.
|
|
struct ServerStatusBadge: View {
|
|
let isRunning: Bool
|
|
let onRestart: (() -> Void)?
|
|
|
|
@Environment(\.colorScheme)
|
|
private var colorScheme
|
|
@State private var isHovered = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(
|
|
isRunning ? AppColors.Fallback.serverRunning(for: colorScheme) : AppColors.Fallback
|
|
.destructive(for: colorScheme)
|
|
)
|
|
.frame(width: 6, height: 6)
|
|
Text(isRunning ? "Running" : "Stopped")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(
|
|
isRunning ? AppColors.Fallback.serverRunning(for: colorScheme) : AppColors.Fallback
|
|
.destructive(for: colorScheme)
|
|
)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(
|
|
isRunning ? AppColors.Fallback.serverRunning(for: colorScheme).opacity(0.1) : AppColors.Fallback
|
|
.destructive(for: colorScheme).opacity(0.1)
|
|
)
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(
|
|
isRunning ? AppColors.Fallback.serverRunning(for: colorScheme).opacity(0.3) : AppColors
|
|
.Fallback.destructive(for: colorScheme).opacity(0.3),
|
|
lineWidth: 0.5
|
|
)
|
|
)
|
|
)
|
|
.opacity(isHovered && !isRunning ? 0.8 : 1.0)
|
|
.scaleEffect(isHovered && !isRunning ? 0.95 : 1.0)
|
|
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
|
.onHover { hovering in
|
|
if !isRunning {
|
|
isHovered = hovering
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
if !isRunning, let onRestart {
|
|
onRestart()
|
|
}
|
|
}
|
|
.help(!isRunning ? "Click to restart server" : "")
|
|
}
|
|
}
|