feat: add Tailscale integration and improve networking documentation (#144)

- Add TailscaleService for status checking and integration
- Add Tailscale section in dashboard settings with status display
- Expand README with detailed setup guides for Tailscale and ngrok
- Show Tailscale hostname and IP when connected
- Add links to download/setup resources for both services

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Billy Irwin 2025-06-29 20:03:44 -07:00 committed by GitHub
parent f1c0554644
commit 9cea558da8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 580 additions and 13 deletions

View file

@ -77,13 +77,47 @@ The server runs as a standalone Bun executable with embedded Node.js modules, pr
## Remote Access Options ## Remote Access Options
### Option 1: Tailscale (Recommended) ### Option 1: Tailscale (Recommended)
1. Install [Tailscale](https://tailscale.com) on your Mac and remote device
2. Access VibeTunnel at `http://[your-mac-name]:4020` [Tailscale](https://tailscale.com) creates a secure peer-to-peer VPN network between your devices. It's the most secure option as traffic stays within your private network without exposing VibeTunnel to the public internet.
**How it works**: Tailscale creates an encrypted WireGuard tunnel between your devices, allowing them to communicate as if they were on the same local network, regardless of their physical location.
**Setup Guide**:
1. Install Tailscale on your Mac: [Download from Mac App Store](https://apps.apple.com/us/app/tailscale/id1475387142) or [Direct Download](https://tailscale.com/download/macos)
2. Install Tailscale on your remote device:
- **iOS**: [Download from App Store](https://apps.apple.com/us/app/tailscale/id1470499037)
- **Android**: [Download from Google Play](https://play.google.com/store/apps/details?id=com.tailscale.ipn)
- **Other platforms**: [All Downloads](https://tailscale.com/download)
3. Sign in to both devices with the same account
4. Find your Mac's Tailscale hostname in the Tailscale menu bar app (e.g., `my-mac.tailnet-name.ts.net`)
5. Access VibeTunnel at `http://[your-tailscale-hostname]:4020`
**Benefits**:
- End-to-end encrypted traffic
- No public internet exposure
- Works behind NAT and firewalls
- Zero configuration after initial setup
### Option 2: ngrok ### Option 2: ngrok
1. Add your ngrok auth token in VibeTunnel settings
2. Enable ngrok tunneling [ngrok](https://ngrok.com) creates secure tunnels to your localhost, making VibeTunnel accessible via a public URL. Perfect for quick sharing or temporary access.
3. Share the generated URL
**How it works**: ngrok establishes a secure tunnel from a public endpoint to your local VibeTunnel server, handling SSL/TLS encryption and providing a unique URL for access.
**Setup Guide**:
1. Create a free ngrok account: [Sign up for ngrok](https://dashboard.ngrok.com/signup)
2. Copy your auth token from the [ngrok dashboard](https://dashboard.ngrok.com/get-started/your-authtoken)
3. Add the token in VibeTunnel settings (Settings → Remote Access → ngrok)
4. Enable ngrok tunneling in VibeTunnel
5. Share the generated `https://[random].ngrok-free.app` URL
**Benefits**:
- Public HTTPS URL with SSL certificate
- No firewall configuration needed
- Built-in request inspection and replay
- Custom domains available (paid plans)
**Note**: Free ngrok URLs change each time you restart the tunnel. Consider a paid plan for persistent URLs.
### Option 3: Local Network ### Option 3: Local Network
1. Set a dashboard password in settings 1. Set a dashboard password in settings

View file

@ -60,7 +60,8 @@ class ServerManager {
var bindAddress: String { var bindAddress: String {
get { get {
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "" let mode = DashboardAccessMode(
rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
) ?? ) ??
.localhost .localhost
return mode.bindAddress return mode.bindAddress
@ -466,7 +467,8 @@ class ServerManager {
let delay = baseDelay * pow(2.0, Double(consecutiveCrashes - 1)) let delay = baseDelay * pow(2.0, Double(consecutiveCrashes - 1))
logger logger
.info("Will restart server after \(delay) seconds (attempt \(self.consecutiveCrashes) of \(maxRetries))" .info(
"Will restart server after \(delay) seconds (attempt \(self.consecutiveCrashes) of \(maxRetries))"
) )
// Wait with exponential backoff // Wait with exponential backoff

View file

@ -0,0 +1,268 @@
import AppKit
import Foundation
import Observation
import os
/// Manages Tailscale integration and status checking.
///
/// `TailscaleService` provides functionality to check if Tailscale is installed
/// and running on the system, and retrieves the device's Tailscale hostname
/// for network access. Unlike ngrok, Tailscale doesn't require auth tokens
/// as it uses system-level authentication.
@Observable
@MainActor
final class TailscaleService {
static let shared = TailscaleService()
/// Logger instance for debugging
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleService")
/// Indicates if Tailscale app is installed on the system
private(set) var isInstalled = false
/// Indicates if Tailscale CLI is available
private(set) var isCLIAvailable = false
/// Indicates if Tailscale is currently running
private(set) var isRunning = false
/// The Tailscale hostname for this device (e.g., "my-mac.tailnet-name.ts.net")
private(set) var tailscaleHostname: String?
/// The Tailscale IP address for this device
private(set) var tailscaleIP: String?
/// Error message if status check fails
private(set) var statusError: String?
/// Path to the tailscale executable
private var tailscalePath: String?
private init() {
Task {
await checkTailscaleStatus()
}
}
/// Checks if Tailscale app is installed
func checkAppInstallation() -> Bool {
let isAppInstalled = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
logger.info("Tailscale app installed: \(isAppInstalled)")
return isAppInstalled
}
/// Checks if Tailscale CLI is available
func checkCLIAvailability() async -> Bool {
let checkPaths = [
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
"/usr/local/bin/tailscale",
"/opt/homebrew/bin/tailscale"
]
for path in checkPaths {
if FileManager.default.fileExists(atPath: path) {
logger.info("Tailscale CLI found at: \(path)")
tailscalePath = path
return true
}
}
// Also check if we can run the tailscale command using which
do {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
process.arguments = ["tailscale"]
// Set up PATH to include common installation directories
var environment = ProcessInfo.processInfo.environment
let additionalPaths = [
"/usr/local/bin",
"/opt/homebrew/bin",
"/Applications/Tailscale.app/Contents/MacOS"
]
if let currentPath = environment["PATH"] {
environment["PATH"] = "\(currentPath):\(additionalPaths.joined(separator: ":"))"
} else {
environment["PATH"] = additionalPaths.joined(separator: ":")
}
process.environment = environment
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!output.isEmpty
{
logger.info("Tailscale CLI found at: \(output)")
tailscalePath = output
return true
}
}
} catch {
logger.debug("Failed to check for tailscale command: \(error)")
}
logger.info("Tailscale CLI not found")
tailscalePath = nil
return false
}
/// Checks the current Tailscale status and updates properties
func checkTailscaleStatus() async {
// First check if app is installed
isInstalled = checkAppInstallation()
guard isInstalled else {
isCLIAvailable = false
isRunning = false
tailscaleHostname = nil
tailscaleIP = nil
statusError = "Tailscale is not installed"
return
}
// Then check if CLI is available
isCLIAvailable = await checkCLIAvailability()
guard isCLIAvailable else {
isRunning = false
tailscaleHostname = nil
tailscaleIP = nil
statusError = nil // No error, just CLI not available
return
}
// If CLI is available, check status
do {
let process = Process()
// Use the discovered tailscale path
if let tailscalePath {
process.executableURL = URL(fileURLWithPath: tailscalePath)
process.arguments = ["status", "--json"]
} else {
// Fallback to env if path not found (shouldn't happen if isCLIAvailable is true)
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["tailscale", "status", "--json"]
// Set up PATH environment variable
var environment = ProcessInfo.processInfo.environment
let additionalPaths = [
"/usr/local/bin",
"/opt/homebrew/bin",
"/Applications/Tailscale.app/Contents/MacOS"
]
if let currentPath = environment["PATH"] {
environment["PATH"] = "\(currentPath):\(additionalPaths.joined(separator: ":"))"
} else {
environment["PATH"] = additionalPaths.joined(separator: ":")
}
process.environment = environment
}
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus == 0 {
// Parse JSON output
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
// Check if we're logged in and connected
if let self_ = json["Self"] as? [String: Any],
let dnsName = self_["DNSName"] as? String
{
// Check online status - it might be missing or false
let online = self_["Online"] as? Bool ?? false
isRunning = online
// Use the DNSName which is already properly formatted for DNS
// Remove trailing dot if present
tailscaleHostname = dnsName.hasSuffix(".") ? String(dnsName.dropLast()) : dnsName
// Get Tailscale IP
if let tailscaleIPs = self_["TailscaleIPs"] as? [String],
let firstIP = tailscaleIPs.first
{
tailscaleIP = firstIP
}
statusError = nil
logger
.info(
"Tailscale status: running=\(online), hostname=\(self.tailscaleHostname ?? "nil"), IP=\(self.tailscaleIP ?? "nil")"
)
} else {
isRunning = false
tailscaleHostname = nil
tailscaleIP = nil
statusError = "Tailscale is not logged in"
logger.warning("Tailscale status check failed - missing required fields in JSON")
logger.debug("JSON keys: \(json.keys.sorted())")
}
} else {
isRunning = false
statusError = "Failed to parse Tailscale status"
}
} else {
// Tailscale CLI returned error
let errorOutput = String(data: data, encoding: .utf8) ?? "Unknown error"
isRunning = false
tailscaleHostname = nil
tailscaleIP = nil
if errorOutput.contains("not logged in") {
statusError = "Tailscale is not logged in"
} else if errorOutput.contains("stopped") {
statusError = "Tailscale is stopped"
} else {
statusError = "Tailscale error: \(errorOutput.trimmingCharacters(in: .whitespacesAndNewlines))"
}
}
} catch {
logger.error("Failed to check Tailscale status: \(error)")
isRunning = false
tailscaleHostname = nil
tailscaleIP = nil
statusError = "Failed to check status: \(error.localizedDescription)"
}
}
/// Opens the Tailscale app
func openTailscaleApp() {
if let url = URL(string: "file:///Applications/Tailscale.app") {
NSWorkspace.shared.open(url)
}
}
/// Opens the Mac App Store page for Tailscale
func openAppStore() {
if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") {
NSWorkspace.shared.open(url)
}
}
/// Opens the Tailscale download page
func openDownloadPage() {
if let url = URL(string: "https://tailscale.com/download/macos") {
NSWorkspace.shared.open(url)
}
}
/// Opens the Tailscale setup guide
func openSetupGuide() {
if let url = URL(string: "https://tailscale.com/kb/1017/install/") {
NSWorkspace.shared.open(url)
}
}
}

View file

@ -24,6 +24,8 @@ struct DashboardSettingsView: View {
private var serverManager private var serverManager
@Environment(NgrokService.self) @Environment(NgrokService.self)
private var ngrokService private var ngrokService
@Environment(TailscaleService.self)
private var tailscaleService
@State private var ngrokAuthToken = "" @State private var ngrokAuthToken = ""
@State private var ngrokStatus: NgrokTunnelStatus? @State private var ngrokStatus: NgrokTunnelStatus?
@ -65,6 +67,12 @@ struct DashboardSettingsView: View {
serverManager: serverManager serverManager: serverManager
) )
TailscaleIntegrationSection(
tailscaleService: tailscaleService,
serverPort: serverPort,
accessMode: accessMode
)
NgrokIntegrationSection( NgrokIntegrationSection(
ngrokEnabled: $ngrokEnabled, ngrokEnabled: $ngrokEnabled,
ngrokAuthToken: $ngrokAuthToken, ngrokAuthToken: $ngrokAuthToken,
@ -647,10 +655,12 @@ private struct NgrokIntegrationSection: View {
Text("ngrok Integration") Text("ngrok Integration")
.font(.headline) .font(.headline)
} footer: { } footer: {
Text("ngrok creates secure tunnels to your dashboard from anywhere.") Text(
.font(.caption) "ngrok creates secure public tunnels to access your terminal sessions from any device (including phones and tablets) via the internet."
.frame(maxWidth: .infinity) )
.multilineTextAlignment(.center) .font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
} }
} }
} }
@ -783,6 +793,253 @@ private struct ErrorView: View {
} }
} }
// MARK: - Tailscale Integration Section
private struct TailscaleIntegrationSection: View {
let tailscaleService: TailscaleService
let serverPort: String
let accessMode: DashboardAccessMode
@State private var statusCheckTimer: Timer?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleIntegrationSection")
var body: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
if tailscaleService.isInstalled {
// Tailscale app is installed
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Tailscale is installed")
.font(.callout)
Spacer()
}
if tailscaleService.isCLIAvailable {
// CLI is available, show status
if tailscaleService.isRunning || tailscaleService.tailscaleHostname != nil {
// Show Tailscale hostname and connection info (even if offline, as user might still
// connect)
VStack(alignment: .leading, spacing: 8) {
if let hostname = tailscaleService.tailscaleHostname {
HStack {
Text("Tailscale hostname:")
.font(.caption)
.foregroundColor(.secondary)
Text(hostname)
.font(.caption)
.textSelection(.enabled)
}
}
if let tailscaleIP = tailscaleService.tailscaleIP {
HStack {
Text("Tailscale IP:")
.font(.caption)
.foregroundColor(.secondary)
Text(tailscaleIP)
.font(.caption)
.textSelection(.enabled)
}
}
// Access URL
if let hostname = tailscaleService.tailscaleHostname {
VStack(alignment: .leading, spacing: 4) {
if accessMode == .localhost {
// Show warning if in localhost-only mode
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 12))
Text(
"Server is in localhost-only mode. Change to 'Network' mode above to access via Tailscale."
)
.font(.caption)
.foregroundColor(.orange)
}
.padding(.vertical, 4)
} else {
// Show the access URL
HStack(spacing: 5) {
Text("Access VibeTunnel at:")
.font(.caption)
.foregroundColor(.secondary)
let urlString = "http://\(hostname):\(serverPort)"
if let url = URL(string: urlString) {
Link(urlString, destination: url)
.font(.caption)
.foregroundStyle(.blue)
}
}
if !tailscaleService.isRunning {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
.font(.system(size: 10))
Text("Tailscale reports as offline but may still be accessible")
.font(.caption2)
.foregroundColor(.orange)
}
}
}
}
}
}
} else {
// CLI available but Tailscale not running/logged in
VStack(alignment: .leading, spacing: 8) {
if let error = tailscaleService.statusError {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
Text(error)
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack {
Button("Open Tailscale") {
tailscaleService.openTailscaleApp()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
if let url = URL(string: "https://tailscale.com/kb/1017/install/") {
Link("Setup Guide", destination: url)
.font(.caption)
}
}
}
}
} else {
// App installed but CLI not available
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Tailscale CLI not available")
.font(.caption)
.foregroundColor(.secondary)
}
Text(
"To see your Tailscale status here, install the Tailscale CLI. You can still use Tailscale - just open the app and connect to your tailnet."
)
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Button("Open Tailscale") {
tailscaleService.openTailscaleApp()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
if let url = URL(string: "https://tailscale.com/kb/1090/install-tailscale-cli/") {
Link("Install CLI", destination: url)
.font(.caption)
}
}
}
}
} else {
// Tailscale is not installed
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Tailscale is not installed")
.font(.callout)
}
Text(
"Tailscale creates a secure peer-to-peer VPN for accessing VibeTunnel from any device - your phone, tablet, or another computer."
)
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Button(action: {
tailscaleService.openAppStore()
}) {
Text("App Store")
}
.buttonStyle(.link)
.controlSize(.small)
Button(action: {
tailscaleService.openDownloadPage()
}) {
Text("Direct Download")
}
.buttonStyle(.link)
.controlSize(.small)
Button(action: {
tailscaleService.openSetupGuide()
}) {
Text("Setup Guide")
}
.buttonStyle(.link)
.controlSize(.small)
}
Button("Check Again") {
Task {
await tailscaleService.checkTailscaleStatus()
}
}
.buttonStyle(.link)
.font(.caption)
}
}
}
} header: {
Text("Tailscale Integration")
.font(.headline)
} footer: {
Text(
"Recommended: Tailscale provides secure, private access to your terminal sessions from any device (including phones and tablets) without exposing VibeTunnel to the public internet."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.task {
// Check status when view appears
logger.info("TailscaleIntegrationSection: Starting initial status check")
await tailscaleService.checkTailscaleStatus()
logger
.info(
"TailscaleIntegrationSection: Status check complete - isInstalled: \(tailscaleService.isInstalled), isCLIAvailable: \(tailscaleService.isCLIAvailable), isRunning: \(tailscaleService.isRunning), hostname: \(tailscaleService.tailscaleHostname ?? "nil")"
)
// Set up timer for automatic updates every 5 seconds
statusCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
Task {
logger.debug("TailscaleIntegrationSection: Running periodic status check")
await tailscaleService.checkTailscaleStatus()
}
}
}
.onDisappear {
// Clean up timer when view disappears
statusCheckTimer?.invalidate()
statusCheckTimer = nil
logger.info("TailscaleIntegrationSection: Stopped status check timer")
}
}
}
// MARK: - Previews // MARK: - Previews
#Preview("Dashboard Settings") { #Preview("Dashboard Settings") {

View file

@ -69,8 +69,9 @@ enum SettingsOpener {
// Check by title // Check by title
if window.isVisible && window.styleMask.contains(.titled) && if window.isVisible && window.styleMask.contains(.titled) &&
(window.title.localizedCaseInsensitiveContains("settings") || (
window.title.localizedCaseInsensitiveContains("preferences") window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")
) )
{ {
return true return true

View file

@ -11,6 +11,7 @@ struct VibeTunnelApp: App {
@State var sessionMonitor = SessionMonitor.shared @State var sessionMonitor = SessionMonitor.shared
@State var serverManager = ServerManager.shared @State var serverManager = ServerManager.shared
@State var ngrokService = NgrokService.shared @State var ngrokService = NgrokService.shared
@State var tailscaleService = TailscaleService.shared
@State var permissionManager = SystemPermissionManager.shared @State var permissionManager = SystemPermissionManager.shared
@State var terminalLauncher = TerminalLauncher.shared @State var terminalLauncher = TerminalLauncher.shared
@ -36,6 +37,7 @@ struct VibeTunnelApp: App {
.environment(sessionMonitor) .environment(sessionMonitor)
.environment(serverManager) .environment(serverManager)
.environment(ngrokService) .environment(ngrokService)
.environment(tailscaleService)
.environment(permissionManager) .environment(permissionManager)
.environment(terminalLauncher) .environment(terminalLauncher)
} }
@ -52,6 +54,7 @@ struct VibeTunnelApp: App {
.environment(sessionMonitor) .environment(sessionMonitor)
.environment(serverManager) .environment(serverManager)
.environment(ngrokService) .environment(ngrokService)
.environment(tailscaleService)
.environment(permissionManager) .environment(permissionManager)
.environment(terminalLauncher) .environment(terminalLauncher)
} else { } else {
@ -66,6 +69,7 @@ struct VibeTunnelApp: App {
.environment(sessionMonitor) .environment(sessionMonitor)
.environment(serverManager) .environment(serverManager)
.environment(ngrokService) .environment(ngrokService)
.environment(tailscaleService)
.environment(permissionManager) .environment(permissionManager)
.environment(terminalLauncher) .environment(terminalLauncher)
} }
@ -90,6 +94,7 @@ struct VibeTunnelApp: App {
.environment(sessionMonitor) .environment(sessionMonitor)
.environment(serverManager) .environment(serverManager)
.environment(ngrokService) .environment(ngrokService)
.environment(tailscaleService)
.environment(permissionManager) .environment(permissionManager)
.environment(terminalLauncher) .environment(terminalLauncher)
} label: { } label: {