mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Ensure server restarts on password change.
This commit is contained in:
parent
99392b53a4
commit
2c276fc67c
3 changed files with 110 additions and 36 deletions
|
|
@ -32,7 +32,15 @@ struct LazyBasicAuthMiddleware<Context: RequestContext>: RouterMiddleware where
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if password protection is enabled
|
// Check if password protection is enabled
|
||||||
guard UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") else {
|
let passwordEnabled = UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled")
|
||||||
|
|
||||||
|
// Check if enabled state changed and clear cache if needed
|
||||||
|
if await passwordCache.shouldRecache(currentEnabledState: passwordEnabled) {
|
||||||
|
await passwordCache.clear()
|
||||||
|
logger.info("Password enabled state changed, cleared cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard passwordEnabled else {
|
||||||
// No password protection, allow request
|
// No password protection, allow request
|
||||||
return try await next(request, context)
|
return try await next(request, context)
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +120,7 @@ struct LazyBasicAuthMiddleware<Context: RequestContext>: RouterMiddleware where
|
||||||
/// Actor to manage password caching in a thread-safe way
|
/// Actor to manage password caching in a thread-safe way
|
||||||
private actor PasswordCache {
|
private actor PasswordCache {
|
||||||
private var cachedPassword: String?
|
private var cachedPassword: String?
|
||||||
|
private var lastEnabledState: Bool?
|
||||||
|
|
||||||
func getPassword() -> String? {
|
func getPassword() -> String? {
|
||||||
cachedPassword
|
cachedPassword
|
||||||
|
|
@ -123,5 +132,14 @@ private actor PasswordCache {
|
||||||
|
|
||||||
func clear() {
|
func clear() {
|
||||||
cachedPassword = nil
|
cachedPassword = nil
|
||||||
|
lastEnabledState = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRecache(currentEnabledState: Bool) -> Bool {
|
||||||
|
if lastEnabledState != currentEnabledState {
|
||||||
|
lastEnabledState = currentEnabledState
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ final class RustServer: ServerProtocol {
|
||||||
if let fileSize = attributes[.size] as? NSNumber {
|
if let fileSize = attributes[.size] as? NSNumber {
|
||||||
logger.info("tty-fwd binary size: \(fileSize.intValue) bytes")
|
logger.info("tty-fwd binary size: \(fileSize.intValue) bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log binary architecture info
|
// Log binary architecture info
|
||||||
logContinuation?.yield(ServerLogEntry(
|
logContinuation?.yield(ServerLogEntry(
|
||||||
level: .debug,
|
level: .debug,
|
||||||
|
|
@ -175,7 +175,7 @@ final class RustServer: ServerProtocol {
|
||||||
let webPublicPath = URL(fileURLWithPath: resourcesPath).appendingPathComponent("web/public")
|
let webPublicPath = URL(fileURLWithPath: resourcesPath).appendingPathComponent("web/public")
|
||||||
let webPublicExists = FileManager.default.fileExists(atPath: webPublicPath.path)
|
let webPublicExists = FileManager.default.fileExists(atPath: webPublicPath.path)
|
||||||
logger.info("Web public directory at \(webPublicPath.path) exists: \(webPublicExists)")
|
logger.info("Web public directory at \(webPublicPath.path) exists: \(webPublicExists)")
|
||||||
|
|
||||||
if !webPublicExists {
|
if !webPublicExists {
|
||||||
logger.error("Web public directory NOT FOUND at: \(webPublicPath.path)")
|
logger.error("Web public directory NOT FOUND at: \(webPublicPath.path)")
|
||||||
logContinuation?.yield(ServerLogEntry(
|
logContinuation?.yield(ServerLogEntry(
|
||||||
|
|
@ -242,21 +242,21 @@ final class RustServer: ServerProtocol {
|
||||||
do {
|
do {
|
||||||
// Start the process (this just launches it and returns immediately)
|
// Start the process (this just launches it and returns immediately)
|
||||||
try await processHandler.runProcess(process)
|
try await processHandler.runProcess(process)
|
||||||
|
|
||||||
// Mark server as running
|
// Mark server as running
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
logger.info("Rust server process started")
|
logger.info("Rust server process started")
|
||||||
|
|
||||||
// Give the process a moment to start before checking for early failures
|
// Give the process a moment to start before checking for early failures
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
// Check if process exited immediately (indicating failure)
|
// Check if process exited immediately (indicating failure)
|
||||||
if !process.isRunning {
|
if !process.isRunning {
|
||||||
isRunning = false
|
isRunning = false
|
||||||
let exitCode = process.terminationStatus
|
let exitCode = process.terminationStatus
|
||||||
logger.error("Process exited immediately with code: \(exitCode)")
|
logger.error("Process exited immediately with code: \(exitCode)")
|
||||||
|
|
||||||
// Try to read any error output
|
// Try to read any error output
|
||||||
var errorDetails = "Exit code: \(exitCode)"
|
var errorDetails = "Exit code: \(exitCode)"
|
||||||
if let stderrPipe = self.stderrPipe {
|
if let stderrPipe = self.stderrPipe {
|
||||||
|
|
@ -265,16 +265,16 @@ final class RustServer: ServerProtocol {
|
||||||
errorDetails += "\nError: \(errorOutput.trimmingCharacters(in: .whitespacesAndNewlines))"
|
errorDetails += "\nError: \(errorOutput.trimmingCharacters(in: .whitespacesAndNewlines))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logContinuation?.yield(ServerLogEntry(
|
logContinuation?.yield(ServerLogEntry(
|
||||||
level: .error,
|
level: .error,
|
||||||
message: "Server failed to start: \(errorDetails)",
|
message: "Server failed to start: \(errorDetails)",
|
||||||
source: .rust
|
source: .rust
|
||||||
))
|
))
|
||||||
|
|
||||||
throw RustServerError.processFailedToStart
|
throw RustServerError.processFailedToStart
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Rust server process started, performing health check...")
|
logger.info("Rust server process started, performing health check...")
|
||||||
logContinuation?.yield(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust))
|
logContinuation?.yield(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust))
|
||||||
|
|
||||||
|
|
@ -318,7 +318,7 @@ final class RustServer: ServerProtocol {
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
// Log more detailed error information
|
// Log more detailed error information
|
||||||
let errorMessage: String
|
let errorMessage: String
|
||||||
if let rustError = error as? RustServerError {
|
if let rustError = error as? RustServerError {
|
||||||
|
|
@ -331,7 +331,7 @@ final class RustServer: ServerProtocol {
|
||||||
} else {
|
} else {
|
||||||
errorMessage = String(describing: error)
|
errorMessage = String(describing: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error("Failed to start Rust server: \(errorMessage)")
|
logger.error("Failed to start Rust server: \(errorMessage)")
|
||||||
logContinuation?.yield(ServerLogEntry(
|
logContinuation?.yield(ServerLogEntry(
|
||||||
level: .error,
|
level: .error,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,34 @@ struct DashboardSettingsView: View {
|
||||||
DashboardAccessMode(rawValue: accessModeString) ?? .localhost
|
DashboardAccessMode(rawValue: accessModeString) ?? .localhost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
|
/// Handles server-specific password updates (adding, changing, or removing passwords)
|
||||||
|
static func updateServerForPasswordChange(action: PasswordAction, logger: Logger) async {
|
||||||
|
let serverManager = ServerManager.shared
|
||||||
|
|
||||||
|
if serverManager.serverMode == .rust {
|
||||||
|
// Rust server requires restart to apply password changes
|
||||||
|
logger.info("Restarting Rust server to \(action.logMessage)")
|
||||||
|
await serverManager.restart()
|
||||||
|
} else {
|
||||||
|
// Hummingbird server just needs cache clear
|
||||||
|
await serverManager.clearAuthCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PasswordAction {
|
||||||
|
case apply
|
||||||
|
case remove
|
||||||
|
|
||||||
|
var logMessage: String {
|
||||||
|
switch self {
|
||||||
|
case .apply: "apply new password"
|
||||||
|
case .remove: "remove password protection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -54,7 +82,8 @@ struct DashboardSettingsView: View {
|
||||||
passwordError: $passwordError,
|
passwordError: $passwordError,
|
||||||
passwordSaved: $passwordSaved,
|
passwordSaved: $passwordSaved,
|
||||||
dashboardKeychain: dashboardKeychain,
|
dashboardKeychain: dashboardKeychain,
|
||||||
savePassword: savePassword
|
savePassword: savePassword,
|
||||||
|
logger: logger
|
||||||
)
|
)
|
||||||
|
|
||||||
ServerConfigurationSection(
|
ServerConfigurationSection(
|
||||||
|
|
@ -156,15 +185,37 @@ struct DashboardSettingsView: View {
|
||||||
password = ""
|
password = ""
|
||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
|
|
||||||
// Clear cached password in LazyBasicAuthMiddleware
|
// Check if we need to switch to network mode
|
||||||
Task {
|
let needsNetworkModeSwitch = accessMode == .localhost
|
||||||
await ServerManager.shared.clearAuthCache()
|
|
||||||
|
if needsNetworkModeSwitch {
|
||||||
|
// Switch to network mode first (this updates ServerManager.bindAddress)
|
||||||
|
accessModeString = DashboardAccessMode.network.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// When password is set for the first time, automatically switch to network mode
|
// Handle server-specific password update
|
||||||
if accessMode == .localhost {
|
Task {
|
||||||
accessModeString = DashboardAccessMode.network.rawValue
|
let serverManager = ServerManager.shared
|
||||||
restartServerWithNewBindAddress()
|
|
||||||
|
if needsNetworkModeSwitch {
|
||||||
|
// If switching to network mode, update bind address before restart
|
||||||
|
serverManager.bindAddress = DashboardAccessMode.network.bindAddress
|
||||||
|
|
||||||
|
// Always restart when switching to network mode (both server types need it)
|
||||||
|
logger.info("Restarting server to apply new password and network mode")
|
||||||
|
await serverManager.restart()
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
SessionMonitor.shared.stopMonitoring()
|
||||||
|
SessionMonitor.shared.startMonitoring()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just password change, no network mode switch
|
||||||
|
await DashboardSettingsView.updateServerForPasswordChange(action: .apply, logger: logger)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
passwordError = "Failed to save password to keychain"
|
passwordError = "Failed to save password to keychain"
|
||||||
|
|
@ -302,6 +353,7 @@ private struct SecuritySection: View {
|
||||||
@Binding var passwordSaved: Bool
|
@Binding var passwordSaved: Bool
|
||||||
let dashboardKeychain: DashboardKeychain
|
let dashboardKeychain: DashboardKeychain
|
||||||
let savePassword: () -> Void
|
let savePassword: () -> Void
|
||||||
|
let logger: Logger
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
|
|
@ -315,9 +367,13 @@ private struct SecuritySection: View {
|
||||||
_ = dashboardKeychain.deletePassword()
|
_ = dashboardKeychain.deletePassword()
|
||||||
showPasswordFields = false
|
showPasswordFields = false
|
||||||
passwordSaved = false
|
passwordSaved = false
|
||||||
// Clear cached password in LazyBasicAuthMiddleware
|
|
||||||
|
// Handle server-specific password removal
|
||||||
Task {
|
Task {
|
||||||
await ServerManager.shared.clearAuthCache()
|
await DashboardSettingsView.updateServerForPasswordChange(
|
||||||
|
action: .remove,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -546,12 +602,12 @@ private struct AccessModeView: View {
|
||||||
private struct PortConfigurationView: View {
|
private struct PortConfigurationView: View {
|
||||||
@Binding var serverPort: String
|
@Binding var serverPort: String
|
||||||
let restartServerWithNewPort: (Int) -> Void
|
let restartServerWithNewPort: (Int) -> Void
|
||||||
|
|
||||||
@State private var portNumber: Int = 4_020
|
@State private var portNumber: Int = 4_020
|
||||||
@State private var portConflict: PortConflict?
|
@State private var portConflict: PortConflict?
|
||||||
@State private var isCheckingPort = false
|
@State private var isCheckingPort = false
|
||||||
@State private var alternativePorts: [Int] = []
|
@State private var alternativePorts: [Int] = []
|
||||||
|
|
||||||
private let serverManager = ServerManager.shared
|
private let serverManager = ServerManager.shared
|
||||||
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "PortConfiguration")
|
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "PortConfiguration")
|
||||||
|
|
||||||
|
|
@ -619,7 +675,7 @@ private struct PortConfigurationView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port conflict warning
|
// Port conflict warning
|
||||||
if let conflict = portConflict {
|
if let conflict = portConflict {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|
@ -627,18 +683,18 @@ private struct PortConfigurationView: View {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
||||||
Text("Port \(conflict.port) is used by \(conflict.process.name)")
|
Text("Port \(conflict.port) is used by \(conflict.process.name)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conflict.alternativePorts.isEmpty {
|
if !conflict.alternativePorts.isEmpty {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("Try port:")
|
Text("Try port:")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
ForEach(conflict.alternativePorts.prefix(3), id: \.self) { port in
|
ForEach(conflict.alternativePorts.prefix(3), id: \.self) { port in
|
||||||
Button(String(port)) {
|
Button(String(port)) {
|
||||||
serverPort = String(port)
|
serverPort = String(port)
|
||||||
|
|
@ -648,7 +704,7 @@ private struct PortConfigurationView: View {
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Choose...") {
|
Button("Choose...") {
|
||||||
showPortPicker()
|
showPortPicker()
|
||||||
}
|
}
|
||||||
|
|
@ -668,7 +724,7 @@ private struct PortConfigurationView: View {
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
||||||
Text("Server failed to start")
|
Text("Server failed to start")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
|
|
@ -680,17 +736,17 @@ private struct PortConfigurationView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkPortAvailability(_ port: Int) async {
|
private func checkPortAvailability(_ port: Int) async {
|
||||||
isCheckingPort = true
|
isCheckingPort = true
|
||||||
defer { isCheckingPort = false }
|
defer { isCheckingPort = false }
|
||||||
|
|
||||||
// Only check if it's not the port we're already successfully using
|
// Only check if it's not the port we're already successfully using
|
||||||
if serverManager.isRunning && Int(serverManager.port) == port {
|
if serverManager.isRunning && Int(serverManager.port) == port {
|
||||||
portConflict = nil
|
portConflict = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let conflict = await PortConflictResolver.shared.detectConflict(on: port) {
|
if let conflict = await PortConflictResolver.shared.detectConflict(on: port) {
|
||||||
// Only show warning for non-VibeTunnel processes
|
// Only show warning for non-VibeTunnel processes
|
||||||
// tty-fwd and other VibeTunnel instances will be auto-killed by ServerManager
|
// tty-fwd and other VibeTunnel instances will be auto-killed by ServerManager
|
||||||
|
|
@ -707,7 +763,7 @@ private struct PortConfigurationView: View {
|
||||||
alternativePorts = []
|
alternativePorts = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func forceQuitConflictingProcess(_ conflict: PortConflict) async {
|
private func forceQuitConflictingProcess(_ conflict: PortConflict) async {
|
||||||
do {
|
do {
|
||||||
try await PortConflictResolver.shared.resolveConflict(conflict)
|
try await PortConflictResolver.shared.resolveConflict(conflict)
|
||||||
|
|
@ -719,7 +775,7 @@ private struct PortConfigurationView: View {
|
||||||
logger.error("Failed to force quit: \(error)")
|
logger.error("Failed to force quit: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showPortPicker() {
|
private func showPortPicker() {
|
||||||
// TODO: Implement port picker dialog
|
// TODO: Implement port picker dialog
|
||||||
// For now, just cycle through alternatives
|
// For now, just cycle through alternatives
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue