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
### 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
1. Add your ngrok auth token in VibeTunnel settings
2. Enable ngrok tunneling
3. Share the generated URL
[ngrok](https://ngrok.com) creates secure tunnels to your localhost, making VibeTunnel accessible via a public URL. Perfect for quick sharing or temporary access.
**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
1. Set a dashboard password in settings

View file

@ -60,7 +60,8 @@ class ServerManager {
var bindAddress: String {
get {
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
let mode = DashboardAccessMode(
rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
) ??
.localhost
return mode.bindAddress
@ -466,7 +467,8 @@ class ServerManager {
let delay = baseDelay * pow(2.0, Double(consecutiveCrashes - 1))
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

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
@Environment(NgrokService.self)
private var ngrokService
@Environment(TailscaleService.self)
private var tailscaleService
@State private var ngrokAuthToken = ""
@State private var ngrokStatus: NgrokTunnelStatus?
@ -65,6 +67,12 @@ struct DashboardSettingsView: View {
serverManager: serverManager
)
TailscaleIntegrationSection(
tailscaleService: tailscaleService,
serverPort: serverPort,
accessMode: accessMode
)
NgrokIntegrationSection(
ngrokEnabled: $ngrokEnabled,
ngrokAuthToken: $ngrokAuthToken,
@ -647,10 +655,12 @@ private struct NgrokIntegrationSection: View {
Text("ngrok Integration")
.font(.headline)
} footer: {
Text("ngrok creates secure tunnels to your dashboard from anywhere.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
Text(
"ngrok creates secure public tunnels to access your terminal sessions from any device (including phones and tablets) via the internet."
)
.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
#Preview("Dashboard Settings") {

View file

@ -69,8 +69,9 @@ enum SettingsOpener {
// Check by title
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

View file

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