feat: implement self-signed certificate support in API and UI

- Added support for self-signed TLS certificates in the ApiService, allowing configuration based on server settings.
- Introduced a toggle in the ServerConnectionPage to enable or disable trusting self-signed certificates.
- Updated localization files to include new strings for self-signed certificate settings in multiple languages.
- Enhanced the OptimizedStorageService to manage trusted servers based on user preferences for self-signed certificates.
- Improved error handling and logging throughout the affected services to ensure clarity and maintainability.
This commit is contained in:
cogwheel0
2025-10-09 01:49:56 +05:30
parent 10658d076a
commit 259fe3f9f0
27 changed files with 428 additions and 37 deletions

View File

@@ -13,6 +13,9 @@ sealed class ServerConfig with _$ServerConfig {
@Default({}) Map<String, String> customHeaders,
DateTime? lastConnected,
@Default(false) bool isActive,
/// Whether to trust self-signed TLS certificates for this server.
@Default(false) bool allowSelfSignedCertificates,
}) = _ServerConfig;
factory ServerConfig.fromJson(Map<String, dynamic> json) =>

View File

@@ -1,8 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
// import 'package:http_parser/http_parser.dart';
// Removed legacy websocket/socket.io imports
import 'package:uuid/uuid.dart';
@@ -62,6 +63,8 @@ class ApiService {
: null,
),
) {
_configureSelfSignedSupport();
// Use API key from server config if provided and no explicit auth token
final effectiveAuthToken = authToken ?? serverConfig.apiKey;
@@ -125,6 +128,55 @@ class ApiService {
}
}
void _configureSelfSignedSupport() {
if (kIsWeb || !serverConfig.allowSelfSignedCertificates) {
return;
}
final baseUri = _parseBaseUri(serverConfig.url);
if (baseUri == null) {
return;
}
final adapter = _dio.httpClientAdapter;
if (adapter is! IOHttpClientAdapter) {
return;
}
adapter.createHttpClient = () {
final client = HttpClient();
final host = baseUri.host.toLowerCase();
final port = baseUri.hasPort ? baseUri.port : null;
client.badCertificateCallback =
(X509Certificate cert, String requestHost, int requestPort) {
if (requestHost.toLowerCase() != host) {
return false;
}
if (port == null) {
return true;
}
return requestPort == port;
};
return client;
};
}
Uri? _parseBaseUri(String baseUrl) {
final trimmed = baseUrl.trim();
if (trimmed.isEmpty) {
return null;
}
Uri? parsed = Uri.tryParse(trimmed);
if (parsed == null) {
return null;
}
if (!parsed.hasScheme) {
parsed =
Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed');
}
return parsed;
}
// Health check
Future<bool> checkHealth() async {
try {

View File

@@ -217,7 +217,11 @@ class BackgroundStreamingHandler {
DebugLogger.stream(
'saveStreamStatesForRecovery called',
scope: 'background',
data: {'streamIds': streamIds, 'reason': reason, 'statesCount': _streamStates.length},
data: {
'streamIds': streamIds,
'reason': reason,
'statesCount': _streamStates.length,
},
);
final statesToSave = streamIds

View File

@@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart';
import '../utils/debug_logger.dart';
import 'secure_credential_storage.dart';
import 'self_signed_certificate_manager.dart';
/// Optimized storage service backed by Hive for non-sensitive data and
/// FlutterSecureStorage for credentials.
@@ -196,6 +197,9 @@ class OptimizedStorageService {
await _secureCredentialStorage.saveServerConfigs(jsonString);
_cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now();
SelfSignedCertificateManager.instance
..ensureInitialized()
..updateTrustedServers(configs);
DebugLogger.log(
'Server configs saved (${configs.length} entries)',
scope: 'storage/optimized',
@@ -215,6 +219,9 @@ class OptimizedStorageService {
if (jsonString == null || jsonString.isEmpty) {
_cache['server_config_count'] = 0;
_cacheTimestamps['server_config_count'] = DateTime.now();
SelfSignedCertificateManager.instance
..ensureInitialized()
..clearTrustedServers();
return const [];
}
@@ -224,12 +231,16 @@ class OptimizedStorageService {
.toList();
_cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now();
SelfSignedCertificateManager.instance
..ensureInitialized()
..updateTrustedServers(configs);
return configs;
} catch (error) {
DebugLogger.log(
'Failed to retrieve server configs: $error',
scope: 'storage/optimized',
);
SelfSignedCertificateManager.instance.clearTrustedServers();
return const [];
}
}

View File

@@ -234,7 +234,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
void unregisterStream(String streamId, {bool saveForRecovery = false}) {
// If app is in background and stream is unregistering, it might be due to
// network interruption - save state for recovery instead of just dropping it
if (_isInBackground && !saveForRecovery && _streamMetadata.containsKey(streamId)) {
if (_isInBackground &&
!saveForRecovery &&
_streamMetadata.containsKey(streamId)) {
DebugLogger.stream(
'PersistentStreamingService: Stream $streamId interrupted in background, saving for recovery',
);

View File

@@ -0,0 +1,23 @@
import '../models/server_config.dart';
import 'self_signed_certificate_manager_io.dart'
if (dart.library.html) 'self_signed_certificate_manager_stub.dart'
as platform;
/// Coordinates opt-in trust for self-signed TLS certificates.
///
/// On IO platforms we install an [HttpOverrides] that whitelists the servers
/// flagged in [ServerConfig.allowSelfSignedCertificates]. On web platforms the
/// helpers are no-ops because browsers manage TLS validation themselves.
class SelfSignedCertificateManager {
const SelfSignedCertificateManager._();
static const SelfSignedCertificateManager instance =
SelfSignedCertificateManager._();
void ensureInitialized() => platform.ensureInitialized();
void updateTrustedServers(Iterable<ServerConfig> configs) =>
platform.updateTrustedServers(configs);
void clearTrustedServers() => platform.clearTrustedServers();
}

View File

@@ -0,0 +1,117 @@
import 'dart:io';
import '../models/server_config.dart';
final _IoSelfSignedCertificateManager _manager =
_IoSelfSignedCertificateManager();
void ensureInitialized() => _manager.ensureInitialized();
void updateTrustedServers(Iterable<ServerConfig> configs) =>
_manager.updateTrustedServers(configs);
void clearTrustedServers() => _manager.clearTrustedServers();
class _IoSelfSignedCertificateManager {
_IoSelfSignedCertificateManager();
_ConduitHttpOverrides? _overrides;
void ensureInitialized() {
if (_overrides != null) return;
final overrides = _ConduitHttpOverrides();
HttpOverrides.global = overrides;
_overrides = overrides;
}
void updateTrustedServers(Iterable<ServerConfig> configs) {
ensureInitialized();
_overrides?.updateTrustedServers(configs);
}
void clearTrustedServers() {
_overrides?.clearTrustedServers();
}
}
class _ConduitHttpOverrides extends HttpOverrides {
final Set<_TrustedEndpoint> _trustedEndpoints = {};
void updateTrustedServers(Iterable<ServerConfig> configs) {
_trustedEndpoints
..clear()
..addAll(
configs
.where((config) => config.allowSelfSignedCertificates)
.map((config) => _TrustedEndpoint.fromUrl(config.url))
.whereType<_TrustedEndpoint>(),
);
}
void clearTrustedServers() {
_trustedEndpoints.clear();
}
bool _shouldTrust(String host, int port) {
for (final endpoint in _trustedEndpoints) {
if (endpoint.matches(host, port)) {
return true;
}
}
return false;
}
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = super.createHttpClient(context);
client.badCertificateCallback =
(X509Certificate cert, String host, int port) =>
_shouldTrust(host, port);
return client;
}
}
class _TrustedEndpoint {
const _TrustedEndpoint({required this.host, this.port});
final String host;
final int? port;
static _TrustedEndpoint? fromUrl(String url) {
final uri = _normalizeUrl(url);
if (uri == null || uri.host.isEmpty) {
return null;
}
final normalizedHost = uri.host.toLowerCase();
final normalizedPort = uri.hasPort ? uri.port : null;
return _TrustedEndpoint(host: normalizedHost, port: normalizedPort);
}
static Uri? _normalizeUrl(String value) {
if (value.trim().isEmpty) {
return null;
}
Uri? parsed = Uri.tryParse(value.trim());
if (parsed == null) {
return null;
}
if (!parsed.hasScheme) {
parsed =
Uri.tryParse('https://${value.trim()}') ??
Uri.tryParse('http://${value.trim()}');
}
return parsed;
}
bool matches(String otherHost, int otherPort) {
final normalizedHost = otherHost.toLowerCase();
if (normalizedHost != host) {
return false;
}
if (port == null) {
return true;
}
return port == otherPort;
}
}

View File

@@ -0,0 +1,7 @@
import '../models/server_config.dart';
void ensureInitialized() {}
void updateTrustedServers(Iterable<ServerConfig> configs) {}
void clearTrustedServers() {}

View File

@@ -107,16 +107,22 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
persistentController.add(data);
},
onDone: () async {
DebugLogger.stream('Source stream onDone fired, hasReceivedData=$hasReceivedData');
DebugLogger.stream(
'Source stream onDone fired, hasReceivedData=$hasReceivedData',
);
// If stream closes immediately without data, it's likely due to backgrounding/network drop
// Not a natural completion
if (!hasReceivedData) {
DebugLogger.stream('Stream closed without data - likely interrupted, not completing');
DebugLogger.stream(
'Stream closed without data - likely interrupted, not completing',
);
// Check if app is backgrounding - if so, finish streaming with whatever we have
await Future.delayed(const Duration(milliseconds: 300));
if (persistentService.isInBackground) {
DebugLogger.stream('App backgrounding during stream - finishing with current content');
DebugLogger.stream(
'App backgrounding during stream - finishing with current content',
);
finishStreaming();
}
// Don't close the controller to prevent cascading completion handlers
@@ -127,14 +133,20 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
await Future.delayed(const Duration(milliseconds: 500));
final isInBg = persistentService.isInBackground;
DebugLogger.stream('Stream onDone check: streamId=$streamId, isInBackground=$isInBg');
DebugLogger.stream(
'Stream onDone check: streamId=$streamId, isInBackground=$isInBg',
);
// Check if we're in background before closing
if (!isInBg) {
DebugLogger.stream('Closing stream controller for $streamId (foreground completion)');
DebugLogger.stream(
'Closing stream controller for $streamId (foreground completion)',
);
persistentController.close();
} else {
DebugLogger.stream('Source stream completed in background for $streamId - keeping open for recovery');
DebugLogger.stream(
'Source stream completed in background for $streamId - keeping open for recovery',
);
// Finish streaming to save the content we have
finishStreaming();
}
@@ -417,7 +429,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (name is String && name.isNotEmpty) {
final msgs = getMessages();
// Quick string check before expensive regex
final exists = (msgs.isNotEmpty) &&
final exists =
(msgs.isNotEmpty) &&
msgs.last.content.contains('name="$name"');
if (!exists) {
final status =
@@ -519,7 +532,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (name is String && name.isNotEmpty) {
final msgs = getMessages();
// Quick string check before expensive regex
final exists = (msgs.isNotEmpty) &&
final exists =
(msgs.isNotEmpty) &&
msgs.last.content.contains('name="$name"');
if (!exists) {
final status =
@@ -549,7 +563,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (name is String && name.isNotEmpty) {
final msgs = getMessages();
// Quick string check before expensive regex
final exists = (msgs.isNotEmpty) &&
final exists =
(msgs.isNotEmpty) &&
msgs.last.content.contains('name="$name"');
if (!exists) {
final status =