diff --git a/README.md b/README.md index 9056c5e..0a54102 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,9 @@ Native端复用现有EasyNode后端,在移动设备上提供服务器管理、 - 首次启动后会在终端自动生成管理员账号密码,登录后请及时修改,避免日志残留敏感信息。 - 请牢记账号密码,出于安全原因,不提供一键重置密码的脚本 -- 默认web端口:**8082** - +- 访问: + - https安全访问:https://ip:8083 【注意:默认启用https自签证书加密访问,首次打开需在浏览器中手动跳过 https 证书错误提示:浏览器页面中点 高级 --> 继续前往】 + - http 内网访问:http://ip:8082 【仓库提供的docker-compose默认仅开放 12.0.0.1 内网访问,切记开放公网访问**请勿使用http**】 ### docker-compose部署 @@ -89,7 +90,7 @@ Native端复用现有EasyNode后端,在移动设备上提供服务器管理、 # 1. 创建easynode目录 mkdir -p /root/easynode && cd /root/easynode -# 2. 下载docker-compose.yml文件(含watchtower) +# 2. 下载docker-compose.yml文件(含watchtower自动更新) wget https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/docker-compose.yml # 3. 启动服务 @@ -104,8 +105,8 @@ docker compose up -d | `GUACD_PORT` | 自建guacd服务PORT | - | docker-compose 已配置 | | `DEBUG` | 启动日志 | `true` | `false`:关闭,`true`:开启 | | `RDP_PORT` | RDP服务端口 | - | 无特殊需求保持默认即可 | -| `ENABLE_HTTPS` | 是否启用HTTPS | `0` | `0`:关闭
`1`:自签证书(适合内网)
`2`:合法证书(适合外网)
外网建议使用 nginx/caddy 进行 HTTPS 转发 | -| `HTTPS_PORT` | HTTPS端口 | `8092` | - | +| `ENABLE_HTTPS` | 是否启用HTTPS | `1` | `0`:关闭
`1`:自签证书(适合内网)
`2`:合法证书(适合外网)
外网建议使用 nginx/caddy 进行 HTTPS 转发 | +| `HTTPS_PORT` | HTTPS端口 | `8092` | 默认启用,请使用 https 访问web端 | | `SSL_CERT_PATH` | HTTPS证书文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 | | `SSL_KEY_PATH` | HTTPS私钥文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 | diff --git a/docker-compose.yml b/docker-compose.yml index 23702a0..31f54c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,8 @@ services: image: docker.cnb.cool/chaoszhu/easynode:latest # cnb自动同步 restart: always ports: - - 8082:8082 + - 127.0.0.1:8082:8082 # http仅内网访问 + - 8092:8092 # https 端口,访问时需手动加 https://ip:8092 volumes: - /root/easynode/db:/easynode/app/db environment: @@ -15,8 +16,8 @@ services: - GUACD_PORT=4822 - HTTP_PORT=8082 - RDP_PORT=8083 - - ENABLE_HTTPS=0 - - HTTPS_PORT=8092 + - ENABLE_HTTPS=1 # 默认自签证书 + - HTTPS_PORT=8092 # https 端口 8092 - SSL_CERT_PATH= - SSL_KEY_PATH= depends_on: diff --git a/native/ios/Podfile.lock b/native/ios/Podfile.lock index 3f775c1..37b4fe8 100644 --- a/native/ios/Podfile.lock +++ b/native/ios/Podfile.lock @@ -14,7 +14,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" SPEC CHECKSUMS: - Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 PODFILE CHECKSUM: 288656d4464589360115d6f81dd5d8f559352730 diff --git a/native/lib/app.dart b/native/lib/app.dart index e4cfc4b..637273b 100644 --- a/native/lib/app.dart +++ b/native/lib/app.dart @@ -14,6 +14,7 @@ import 'core/api/cookie_store.dart'; import 'core/api/api_result.dart'; import 'core/storage/app_storage.dart'; import 'core/storage/secure_storage.dart'; +import 'core/security/server_certificate_trust.dart'; import 'features/auth/auth_session.dart'; import 'features/auth/login_controller.dart'; import 'features/auth/login_page.dart'; @@ -42,6 +43,7 @@ class _Bootstrap { required this.initialPassword, required this.initialAuthState, required this.initialIpAccessDenied, + required this.certificateTrust, }); final AppStorage appStorage; @@ -52,6 +54,7 @@ class _Bootstrap { final String initialPassword; final AuthState initialAuthState; final bool initialIpAccessDenied; + final ServerCertificateTrustStore certificateTrust; } class EasyNodeApp extends StatelessWidget { @@ -65,6 +68,7 @@ class EasyNodeApp extends StatelessWidget { final secureWrapper = SecureAppStorage(secure); final appStorage = AppStorage(prefs); final cookieStore = SessionCookieStore(secureWrapper); + final certificateTrust = ServerCertificateTrustStore(secureWrapper); final packageInfo = await PackageInfo.fromPlatform(); final appVersion = packageInfo.buildNumber.isEmpty ? packageInfo.version @@ -96,11 +100,13 @@ class EasyNodeApp extends StatelessWidget { deviceId.isNotEmpty; if (hasStoredLogin) { + await certificateTrust.prepare(appStorage.serverAddress); final api = ApiClient( serverAddress: appStorage.serverAddress, cookieStore: cookieStore, token: token, appVersion: appVersion, + certificateTrust: certificateTrust, ); try { final pubKey = await api.getPublicKey(); @@ -150,6 +156,7 @@ class EasyNodeApp extends StatelessWidget { initialPassword: initialPassword, initialAuthState: initialAuthState, initialIpAccessDenied: initialIpAccessDenied, + certificateTrust: certificateTrust, ), ); } @@ -161,6 +168,7 @@ class EasyNodeApp extends StatelessWidget { appStorageProvider.overrideWithValue(_b.appStorage), secureStorageProvider.overrideWithValue(_b.secureStorage), cookieStoreProvider.overrideWithValue(_b.cookieStore), + certificateTrustProvider.overrideWithValue(_b.certificateTrust), authProvider.overrideWith( (ref) => AuthNotifier(ref, _b.initialAuthState), ), @@ -210,6 +218,7 @@ class _AppRootState extends ConsumerState<_AppRoot> { super.initState(); _loginController = LoginController( apiClientFactory: _buildAnonymousApiClient, + certificateTrust: ref.read(certificateTrustProvider), )..onLoginSuccess(_onLoginSuccess); // The restored client is constructed before ProviderScope exists. Bind // its session-failure handler once the global coordinator is available. @@ -239,6 +248,7 @@ class _AppRootState extends ConsumerState<_AppRoot> { cookieStore: ref.read(cookieStoreProvider), token: token, appVersion: widget.appVersion, + certificateTrust: ref.read(certificateTrustProvider), ); } diff --git a/native/lib/core/api/api_client.dart b/native/lib/core/api/api_client.dart index f80c948..981a4d4 100644 --- a/native/lib/core/api/api_client.dart +++ b/native/lib/core/api/api_client.dart @@ -1,10 +1,11 @@ -import 'dart:io' show Platform; +import 'dart:io' show HandshakeException, Platform; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'api_result.dart'; import 'cookie_store.dart'; +import '../security/server_certificate_trust.dart'; const String _fallbackNativeAppVersion = 'unknown'; const String ipAccessDeniedCode = 'IP_ACCESS_DENIED'; @@ -125,7 +126,10 @@ class ApiClient { SessionFailureHandler? onSessionFailure, String? appVersion, Dio? dio, - }) : _cookieStore = cookieStore, + ServerCertificateTrustStore? certificateTrust, + }) : _serverAddress = serverAddress, + _certificateTrust = certificateTrust, + _cookieStore = cookieStore, _token = token, _onSessionFailure = onSessionFailure, _dio = @@ -140,6 +144,9 @@ class ApiClient { }, ), ) { + if (dio == null && certificateTrust != null) { + _dio.httpClientAdapter = certificateTrust.createDioAdapter(); + } if (kDebugMode) { _dio.interceptors.add( InterceptorsWrapper( @@ -195,6 +202,8 @@ class ApiClient { } final Dio _dio; + final String _serverAddress; + final ServerCertificateTrustStore? _certificateTrust; final SessionCookieStore _cookieStore; SessionFailureHandler? _onSessionFailure; String? _token; @@ -255,6 +264,14 @@ class ApiClient { try { return _asJson(await send()); } on DioException catch (error) { + final certificate = _certificateTrust?.pendingCertificateFor( + _serverAddress, + ); + if (certificate != null && + (error.type == DioExceptionType.badCertificate || + error.error is HandshakeException)) { + throw UntrustedServerCertificateException(certificate); + } final failure = apiFailureFromDioException(error); if (failure is ApiSessionFailure) { final handler = _onSessionFailure; diff --git a/native/lib/core/security/server_certificate_trust.dart b/native/lib/core/security/server_certificate_trust.dart new file mode 100644 index 0000000..7d635d3 --- /dev/null +++ b/native/lib/core/security/server_certificate_trust.dart @@ -0,0 +1,192 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/io.dart'; +import 'package:web_socket/io_web_socket.dart'; +import 'package:web_socket/web_socket.dart' as ws; + +import '../storage/secure_storage.dart'; + +class PresentedServerCertificate { + const PresentedServerCertificate({ + required this.origin, + required this.fingerprint, + this.previousFingerprint, + }); + + final String origin; + final String fingerprint; + final String? previousFingerprint; + + String get displayFingerprint => formatCertificateFingerprint(fingerprint); + bool get replacesTrustedCertificate => previousFingerprint != null; +} + +class UntrustedServerCertificateException implements Exception { + const UntrustedServerCertificateException(this.certificate); + + final PresentedServerCertificate certificate; + + @override + String toString() => 'Untrusted certificate for ${certificate.origin}'; +} + +String canonicalServerOrigin(String address) { + final uri = Uri.parse(address); + final scheme = switch (uri.scheme.toLowerCase()) { + 'wss' => 'https', + 'ws' => 'http', + final value => value, + }; + return Uri( + scheme: scheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + ).origin; +} + +String formatCertificateFingerprint(String fingerprint) { + final normalized = fingerprint.replaceAll(':', '').toUpperCase(); + final pairs = []; + for (var index = 0; index < normalized.length; index += 2) { + pairs.add(normalized.substring(index, index + 2)); + } + return pairs.join(':'); +} + +class ServerCertificateTrustStore { + ServerCertificateTrustStore(this._storage); + + final SecureAppStorage _storage; + final Map _trustedFingerprints = {}; + final Map _pendingCertificates = {}; + final Set _loadedOrigins = {}; + HttpClient? _systemWebSocketClient; + HttpClient? _pinnedWebSocketClient; + + Future prepare(String serverAddress) async { + final origin = canonicalServerOrigin(serverAddress); + if (_loadedOrigins.contains(origin)) return; + final stored = await _storage.readServerCertificateFingerprint(origin); + if (stored != null && stored.isNotEmpty) { + final normalized = _normalizeFingerprint(stored); + if (_isValidFingerprint(normalized)) { + _trustedFingerprints[origin] = normalized; + } + } + _loadedOrigins.add(origin); + } + + PresentedServerCertificate? pendingCertificateFor(String serverAddress) { + return _pendingCertificates[canonicalServerOrigin(serverAddress)]; + } + + Future trust(PresentedServerCertificate certificate) async { + await _storage.writeServerCertificateFingerprint( + certificate.origin, + certificate.fingerprint, + ); + _trustedFingerprints[certificate.origin] = certificate.fingerprint; + _loadedOrigins.add(certificate.origin); + _pendingCertificates.remove(certificate.origin); + } + + IOHttpClientAdapter createDioAdapter() { + return IOHttpClientAdapter( + createHttpClient: () { + final client = HttpClient(); + client.badCertificateCallback = _acceptBadCertificate; + return client; + }, + validateCertificate: _validateCertificate, + ); + } + + Future connectWebSocket( + Uri uri, { + Iterable? protocols, + Map? headers, + }) async { + final origin = canonicalServerOrigin(uri.toString()); + final hasPinnedCertificate = _trustedFingerprints.containsKey(origin); + final client = _webSocketClient(pinned: hasPinnedCertificate); + final rawSocket = await WebSocket.connect( + uri.toString(), + protocols: protocols, + headers: headers, + customClient: client, + ); + return IOWebSocket.fromWebSocket(rawSocket); + } + + HttpClient _webSocketClient({required bool pinned}) { + if (pinned) { + return _pinnedWebSocketClient ??= HttpClient( + context: SecurityContext(withTrustedRoots: false), + )..badCertificateCallback = _acceptBadCertificate; + } + return _systemWebSocketClient ??= HttpClient() + ..badCertificateCallback = _acceptBadCertificate; + } + + bool _acceptBadCertificate( + X509Certificate certificate, + String host, + int port, + ) { + final presented = _presentedCertificate(certificate, host, port); + final trusted = _trustedFingerprints[presented.origin]; + if (trusted == presented.fingerprint) { + _pendingCertificates.remove(presented.origin); + return true; + } + _pendingCertificates[presented.origin] = presented; + return false; + } + + bool _validateCertificate( + X509Certificate? certificate, + String host, + int port, + ) { + // Plain HTTP responses do not have a peer certificate. The TLS trust + // policy must not change the app's existing HTTP behaviour. + if (certificate == null) return true; + final presented = _presentedCertificate(certificate, host, port); + final trusted = _trustedFingerprints[presented.origin]; + if (trusted == null) { + _pendingCertificates.remove(presented.origin); + return true; + } + if (trusted == presented.fingerprint) { + _pendingCertificates.remove(presented.origin); + return true; + } + _pendingCertificates[presented.origin] = presented; + return false; + } + + PresentedServerCertificate _presentedCertificate( + X509Certificate certificate, + String host, + int port, + ) { + final origin = Uri( + scheme: 'https', + host: host, + port: port == 443 ? null : port, + ).origin; + final fingerprint = sha256.convert(certificate.der).toString(); + return PresentedServerCertificate( + origin: origin, + fingerprint: fingerprint, + previousFingerprint: _trustedFingerprints[origin], + ); + } + + String _normalizeFingerprint(String value) => + value.replaceAll(':', '').trim().toLowerCase(); + + bool _isValidFingerprint(String value) => + RegExp(r'^[0-9a-f]{64}$').hasMatch(value); +} diff --git a/native/lib/core/storage/secure_storage.dart b/native/lib/core/storage/secure_storage.dart index 41c40a2..a68b40f 100644 --- a/native/lib/core/storage/secure_storage.dart +++ b/native/lib/core/storage/secure_storage.dart @@ -37,4 +37,21 @@ class SecureAppStorage { Future writeDeviceId(String value) => _storage.write(key: 'loginDeviceId', value: value); Future deleteDeviceId() => _storage.delete(key: 'loginDeviceId'); + + String _certificateFingerprintKey(String serverOrigin) => + 'serverCertificateFingerprint:$serverOrigin'; + + Future readServerCertificateFingerprint(String serverOrigin) { + return _storage.read(key: _certificateFingerprintKey(serverOrigin)); + } + + Future writeServerCertificateFingerprint( + String serverOrigin, + String fingerprint, + ) { + return _storage.write( + key: _certificateFingerprintKey(serverOrigin), + value: fingerprint, + ); + } } diff --git a/native/lib/features/ai_agent/agent_socket_client.dart b/native/lib/features/ai_agent/agent_socket_client.dart index efaf70c..cf0f9ca 100644 --- a/native/lib/features/ai_agent/agent_socket_client.dart +++ b/native/lib/features/ai_agent/agent_socket_client.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:socket_io_client/socket_io_client.dart' as sio; import '../../core/api/cookie_store.dart'; +import '../../core/security/server_certificate_trust.dart'; import '../auth/auth_session.dart'; import 'agent_models.dart'; @@ -38,11 +39,14 @@ class AgentSocketClient { AgentSocketClient({ required AuthSession authSession, required SessionCookieStore cookieStore, + required ServerCertificateTrustStore certificateTrust, }) : _authSession = authSession, - _cookieStore = cookieStore; + _cookieStore = cookieStore, + _certificateTrust = certificateTrust; final AuthSession _authSession; final SessionCookieStore _cookieStore; + final ServerCertificateTrustStore _certificateTrust; final _events = StreamController>.broadcast(); final _connections = StreamController.broadcast(); final _errors = StreamController.broadcast(); @@ -92,6 +96,7 @@ class AgentSocketClient { 'Cookie': cookie, 'Origin': _authSession.serverAddress, }) + .setWebSocketConnector(_certificateTrust.connectWebSocket) .disableAutoConnect() .disableReconnection() .build(); diff --git a/native/lib/features/auth/login_controller.dart b/native/lib/features/auth/login_controller.dart index 34c0c13..21dd9cb 100644 --- a/native/lib/features/auth/login_controller.dart +++ b/native/lib/features/auth/login_controller.dart @@ -1,6 +1,7 @@ import '../../core/api/api_client.dart'; import '../../core/api/api_result.dart'; import '../../core/crypto/rsa_crypto.dart'; +import '../../core/security/server_certificate_trust.dart'; import '../../core/utils/jwt_expiry.dart'; import '../../core/utils/validators.dart'; import 'auth_session.dart'; @@ -13,6 +14,7 @@ class LoginResult { this.message = '', this.messageKey, this.session, + this.certificate, }); /// `true` only when login fully succeeded. @@ -32,6 +34,10 @@ class LoginResult { final String? messageKey; final AuthSession? session; + + final PresentedServerCertificate? certificate; + + bool get requiresCertificateConfirmation => certificate != null; } /// Builds an [ApiClient] for a given server address. Allows tests to inject @@ -44,9 +50,13 @@ typedef LoginSuccessHandler = /// Orchestrates the native client login flow. class LoginController { - LoginController({required ApiClientFactory apiClientFactory, RsaCrypto? rsa}) - : _apiClientFactory = apiClientFactory, - _rsa = rsa ?? RsaCrypto(); + LoginController({ + required ApiClientFactory apiClientFactory, + RsaCrypto? rsa, + ServerCertificateTrustStore? certificateTrust, + }) : _apiClientFactory = apiClientFactory, + _rsa = rsa ?? RsaCrypto(), + _certificateTrust = certificateTrust; /// Test factory that returns a controller without a real HTTP client. Real /// network calls will fail because the factory throws when invoked. @@ -57,6 +67,15 @@ class LoginController { final ApiClientFactory _apiClientFactory; final RsaCrypto _rsa; + final ServerCertificateTrustStore? _certificateTrust; + + Future trustCertificate(PresentedServerCertificate certificate) async { + final trust = _certificateTrust; + if (trust == null) { + throw StateError('Certificate trust is not configured'); + } + await trust.trust(certificate); + } Future login({ required String serverAddress, @@ -93,8 +112,9 @@ class LoginController { ); } - final api = _apiClientFactory(normalized); try { + await _certificateTrust?.prepare(normalized); + final api = _apiClientFactory(normalized); final publicKey = await api.getPublicKey(); final ciphertext = _rsa.encryptPassword(publicKey, password); final response = await api.postJson('/login', { @@ -130,6 +150,8 @@ class LoginController { await onLoginSuccess(session, savePassword ? password : null); } return LoginResult(success: true, session: session); + } on UntrustedServerCertificateException catch (error) { + return LoginResult(certificate: error.certificate); } on ApiFailure catch (error) { return LoginResult(message: error.message); } catch (error) { diff --git a/native/lib/features/auth/login_page.dart b/native/lib/features/auth/login_page.dart index 9235f17..615d5f3 100644 --- a/native/lib/features/auth/login_page.dart +++ b/native/lib/features/auth/login_page.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../core/ui/app_color_theme.dart'; import '../../core/utils/jwt_expiry.dart'; import '../../core/utils/validators.dart'; +import '../../core/security/server_certificate_trust.dart'; import '../../l10n/app_localizations.dart'; import '../../state/package_info_provider.dart'; import 'auth_session.dart'; @@ -115,6 +116,20 @@ class _LoginPageState extends State { await _submit(); return; } + if (result.requiresCertificateConfirmation) { + setState(() => _submitting = false); + final certificate = result.certificate!; + final accepted = await _confirmCertificate(certificate); + if (!mounted || accepted != true) return; + try { + await widget.controller.trustCertificate(certificate); + } catch (error) { + if (mounted) setState(() => _errorMessage = error.toString()); + return; + } + await _submit(); + return; + } if (!result.success || result.session == null) { setState(() => _errorMessage = _resolveErrorMessage(result, l)); return; @@ -146,6 +161,69 @@ class _LoginPageState extends State { ); } + Future _confirmCertificate(PresentedServerCertificate certificate) { + final l = AppLocalizations.of(context); + final previous = certificate.previousFingerprint; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text( + l.tr( + certificate.replacesTrustedCertificate + ? 'login.certificateChangedTitle' + : 'login.certificateTitle', + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.tr( + certificate.replacesTrustedCertificate + ? 'login.certificateChangedBody' + : 'login.certificateBody', + ), + ), + const SizedBox(height: 16), + Text(l.tr('login.certificateServer')), + const SizedBox(height: 4), + SelectableText(certificate.origin), + const SizedBox(height: 12), + Text(l.tr('login.certificateFingerprint')), + const SizedBox(height: 4), + SelectableText( + certificate.displayFingerprint, + style: const TextStyle(fontFamily: 'monospace'), + ), + if (previous != null) ...[ + const SizedBox(height: 12), + Text(l.tr('login.certificatePreviousFingerprint')), + const SizedBox(height: 4), + SelectableText( + formatCertificateFingerprint(previous), + style: const TextStyle(fontFamily: 'monospace'), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l.tr('common.cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l.tr('login.trustCertificate')), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); diff --git a/native/lib/features/docker/docker_session_manager.dart b/native/lib/features/docker/docker_session_manager.dart index 5503e8e..9035544 100644 --- a/native/lib/features/docker/docker_session_manager.dart +++ b/native/lib/features/docker/docker_session_manager.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show ChangeNotifier; import '../../core/api/cookie_store.dart'; +import '../../core/security/server_certificate_trust.dart'; import '../auth/auth_session.dart'; import '../servers/server_model.dart'; import 'docker_container.dart'; @@ -61,6 +62,7 @@ class DockerSessionManager extends ChangeNotifier { required ServerModel server, required AuthSession authSession, required SessionCookieStore cookieStore, + required ServerCertificateTrustStore certificateTrust, }) async { final existing = _sessions[server.id]; if (existing != null) { @@ -78,6 +80,7 @@ class DockerSessionManager extends ChangeNotifier { final client = DockerSocketClient( authSession: authSession, cookieStore: cookieStore, + certificateTrust: certificateTrust, hostId: server.id, ); final session = DockerSessionState(server: server, client: client); diff --git a/native/lib/features/docker/docker_socket_client.dart b/native/lib/features/docker/docker_socket_client.dart index 393dc79..b37cc4b 100644 --- a/native/lib/features/docker/docker_socket_client.dart +++ b/native/lib/features/docker/docker_socket_client.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:socket_io_client/socket_io_client.dart' as sio; import '../../core/api/cookie_store.dart'; +import '../../core/security/server_certificate_trust.dart'; import '../auth/auth_session.dart'; import 'docker_container.dart'; @@ -12,12 +13,15 @@ class DockerSocketClient { DockerSocketClient({ required AuthSession authSession, required SessionCookieStore cookieStore, + required ServerCertificateTrustStore certificateTrust, required this.hostId, }) : _authSession = authSession, - _cookieStore = cookieStore; + _cookieStore = cookieStore, + _certificateTrust = certificateTrust; final AuthSession _authSession; final SessionCookieStore _cookieStore; + final ServerCertificateTrustStore _certificateTrust; final String hostId; final StreamController> _containersController = @@ -72,6 +76,7 @@ class DockerSocketClient { 'Cookie': cookie, 'Origin': _authSession.serverAddress, }) + .setWebSocketConnector(_certificateTrust.connectWebSocket) .disableAutoConnect() .disableReconnection() .build(); diff --git a/native/lib/features/docker/docker_tab.dart b/native/lib/features/docker/docker_tab.dart index e1a63ad..0cc1056 100644 --- a/native/lib/features/docker/docker_tab.dart +++ b/native/lib/features/docker/docker_tab.dart @@ -113,6 +113,7 @@ class _DockerPanelState extends ConsumerState { server: server, authSession: authSession, cookieStore: ref.read(cookieStoreProvider), + certificateTrust: ref.read(certificateTrustProvider), ); } catch (error) { if (!mounted) return; diff --git a/native/lib/l10n/strings_en.dart b/native/lib/l10n/strings_en.dart index 9eb82c2..905766d 100644 --- a/native/lib/l10n/strings_en.dart +++ b/native/lib/l10n/strings_en.dart @@ -44,6 +44,17 @@ const Map stringsEn = { 'login.httpRiskTitle': 'HTTP is not encrypted', 'login.httpRiskBody': 'All your data may be stolen. It is recommended to use HTTPS(only in intranet environments).', + 'login.certificateTitle': 'Cannot verify server certificate', + 'login.certificateBody': + 'This certificate is not trusted by the system. Verify the server and fingerprint before permanently trusting it.', + 'login.certificateChangedTitle': 'Server certificate changed', + 'login.certificateChangedBody': + 'The server certificate differs from the certificate trusted previously. Cancel unless you know the server certificate was replaced.', + 'login.certificateServer': 'Server', + 'login.certificateFingerprint': 'New SHA-256 fingerprint', + 'login.certificatePreviousFingerprint': + 'Previously trusted SHA-256 fingerprint', + 'login.trustCertificate': 'Trust this certificate', 'login.errEmptyUsername': 'Please enter a username', 'login.errEmptyPassword': 'Please enter a password', 'login.errInvalidServer': 'Please enter a valid server address', diff --git a/native/lib/l10n/strings_zh.dart b/native/lib/l10n/strings_zh.dart index f6ac2c7..c74e35f 100644 --- a/native/lib/l10n/strings_zh.dart +++ b/native/lib/l10n/strings_zh.dart @@ -43,6 +43,14 @@ const Map stringsZh = { 'login.failed': '登录失败', 'login.httpRiskTitle': 'HTTP 连接未加密', 'login.httpRiskBody': 'http协议下存在数据泄露风险,公网使用请配置https协议。是否继续?', + 'login.certificateTitle': '无法验证服务器证书', + 'login.certificateBody': '该证书不是系统信任的证书。确认服务器和指纹无误后,可以永久信任此证书。', + 'login.certificateChangedTitle': '服务器证书已发生变化', + 'login.certificateChangedBody': '服务器返回的证书与此前信任的证书不同。除非你确认服务器已更换证书,否则请取消连接。', + 'login.certificateServer': '服务器', + 'login.certificateFingerprint': '新证书 SHA-256 指纹', + 'login.certificatePreviousFingerprint': '此前信任的 SHA-256 指纹', + 'login.trustCertificate': '信任此证书', 'login.errEmptyUsername': '请输入用户名', 'login.errEmptyPassword': '请输入密码', 'login.errInvalidServer': '请输入有效的服务端地址', diff --git a/native/lib/state/agent_providers.dart b/native/lib/state/agent_providers.dart index 45de8a3..163abd5 100644 --- a/native/lib/state/agent_providers.dart +++ b/native/lib/state/agent_providers.dart @@ -191,6 +191,7 @@ final agentControllerProvider = final socket = AgentSocketClient( authSession: session, cookieStore: ref.watch(cookieStoreProvider), + certificateTrust: ref.watch(certificateTrustProvider), ); return AgentStateNotifier( repository: ref.watch(agentRepositoryProvider), diff --git a/native/lib/state/storage_providers.dart b/native/lib/state/storage_providers.dart index 6d5e910..c5a7bef 100644 --- a/native/lib/state/storage_providers.dart +++ b/native/lib/state/storage_providers.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api/cookie_store.dart'; import '../core/storage/app_storage.dart'; import '../core/storage/secure_storage.dart'; +import '../core/security/server_certificate_trust.dart'; /// All four storage providers are bootstrap-only — `EasyNodeApp.bootstrap` /// constructs the concrete instances once and overrides them on the root @@ -18,3 +19,7 @@ final secureStorageProvider = Provider((ref) { final cookieStoreProvider = Provider((ref) { throw UnimplementedError('cookieStoreProvider must be overridden'); }); + +final certificateTrustProvider = Provider((ref) { + throw UnimplementedError('certificateTrustProvider must be overridden'); +}); diff --git a/native/pubspec.lock b/native/pubspec.lock index 82dbb2f..1b8d450 100644 --- a/native/pubspec.lock +++ b/native/pubspec.lock @@ -122,7 +122,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -1037,7 +1037,7 @@ packages: source: hosted version: "1.1.1" web_socket: - dependency: transitive + dependency: "direct main" description: name: web_socket sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" diff --git a/native/pubspec.yaml b/native/pubspec.yaml index e9fc8a9..c89ba7a 100644 --- a/native/pubspec.yaml +++ b/native/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: sdk: flutter intl: any dio: ^5.7.0 + crypto: ^3.0.7 cookie_jar: ^4.0.8 dio_cookie_manager: ^3.1.1 flutter_secure_storage: ^9.2.2 @@ -41,6 +42,7 @@ dependencies: url_launcher: ^6.3.1 package_info_plus: ^8.1.0 socket_io_client: ^3.1.6 + web_socket: ^1.0.1 flutter_markdown_plus: ^1.0.12 dev_dependencies: diff --git a/native/test/core/security/server_certificate_trust_test.dart b/native/test/core/security/server_certificate_trust_test.dart new file mode 100644 index 0000000..8181b9e --- /dev/null +++ b/native/test/core/security/server_certificate_trust_test.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:easynode_native/core/security/server_certificate_trust.dart'; +import 'package:easynode_native/core/storage/secure_storage.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_socket/web_socket.dart' as ws; + +void main() { + test('canonicalizes HTTP and WebSocket origins consistently', () { + expect( + canonicalServerOrigin('https://100.74.175.1:8092/path'), + 'https://100.74.175.1:8092', + ); + expect( + canonicalServerOrigin('wss://100.74.175.1:8092/docker/'), + 'https://100.74.175.1:8092', + ); + expect( + canonicalServerOrigin('https://example.com:443'), + 'https://example.com', + ); + }); + + test('formats a SHA-256 fingerprint for display', () { + expect(formatCertificateFingerprint('aabbccdd'), 'AA:BB:CC:DD'); + expect(formatCertificateFingerprint('AA:BB:CC:DD'), 'AA:BB:CC:DD'); + }); + + test('keeps plain HTTP requests working', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final subscription = server.listen((request) async { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'status': 200})); + await request.response.close(); + }); + final trust = ServerCertificateTrustStore( + SecureAppStorage(const FlutterSecureStorage()), + ); + final dio = Dio()..httpClientAdapter = trust.createDioAdapter(); + + try { + final response = await dio.get( + 'http://${server.address.address}:${server.port}/health', + ); + expect(response.statusCode, HttpStatus.ok); + expect(response.data, {'status': 200}); + } finally { + dio.close(force: true); + await server.close(force: true); + await subscription.cancel(); + } + }); + + test('keeps plain WebSocket connections working', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final subscription = server.transform(WebSocketTransformer()).listen(( + socket, + ) { + socket.listen(socket.add); + }); + final trust = ServerCertificateTrustStore( + SecureAppStorage(const FlutterSecureStorage()), + ); + final client = await trust.connectWebSocket( + Uri.parse('ws://${server.address.address}:${server.port}/socket'), + ); + + try { + final event = client.events.first; + client.sendText('ping'); + final received = await event; + expect(received, isA()); + expect((received as ws.TextDataReceived).text, 'ping'); + } finally { + await client.close(); + await server.close(force: true); + await subscription.cancel(); + } + }); +} diff --git a/native/test/features/auth/login_controller_test.dart b/native/test/features/auth/login_controller_test.dart index 1cad069..b8d4dae 100644 --- a/native/test/features/auth/login_controller_test.dart +++ b/native/test/features/auth/login_controller_test.dart @@ -3,6 +3,7 @@ import 'package:easynode_native/core/api/api_result.dart'; import 'package:easynode_native/core/api/cookie_store.dart'; import 'package:easynode_native/core/crypto/rsa_crypto.dart'; import 'package:easynode_native/core/storage/secure_storage.dart'; +import 'package:easynode_native/core/security/server_certificate_trust.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:easynode_native/features/auth/login_controller.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -37,6 +38,47 @@ class _FakeRsaCrypto extends RsaCrypto { 'encrypted-password'; } +class _MemorySecureStorage extends SecureAppStorage { + _MemorySecureStorage() : super(const FlutterSecureStorage()); + + final Map fingerprints = {}; + + @override + Future readServerCertificateFingerprint(String serverOrigin) async { + return fingerprints[serverOrigin]; + } + + @override + Future writeServerCertificateFingerprint( + String serverOrigin, + String fingerprint, + ) async { + fingerprints[serverOrigin] = fingerprint; + } +} + +class _CertificateFailureApiClient extends ApiClient { + _CertificateFailureApiClient(this.certificate) + : super( + serverAddress: certificate.origin, + cookieStore: SessionCookieStore( + SecureAppStorage(const FlutterSecureStorage()), + ), + ); + + final PresentedServerCertificate certificate; + + @override + Future getPublicKey() async { + throw UntrustedServerCertificateException(certificate); + } +} + +PresentedServerCertificate _certificate() => PresentedServerCertificate( + origin: 'https://100.74.175.1:8092', + fingerprint: List.filled(32, 'ab').join(), +); + void main() { test('blocks http login until user confirms risk', () async { final controller = LoginController.fake(); @@ -123,4 +165,36 @@ void main() { expect(result.success, isFalse); expect(result.message, 'session initialization failed'); }); + + test( + 'returns an untrusted certificate and persists explicit trust', + () async { + final secureStorage = _MemorySecureStorage(); + final trust = ServerCertificateTrustStore(secureStorage); + final certificate = _certificate(); + final controller = LoginController( + apiClientFactory: (_, {String? token}) => + _CertificateFailureApiClient(certificate), + certificateTrust: trust, + ); + + final result = await controller.login( + serverAddress: certificate.origin, + username: 'root', + password: 'secret', + mfa2Token: '', + httpRiskAccepted: false, + savePassword: false, + ); + + expect(result.requiresCertificateConfirmation, isTrue); + expect(result.certificate, same(certificate)); + + await controller.trustCertificate(certificate); + expect( + secureStorage.fingerprints[certificate.origin], + certificate.fingerprint, + ); + }, + ); } diff --git a/native/test/features/auth/login_page_test.dart b/native/test/features/auth/login_page_test.dart index 6626638..f79f8f7 100644 --- a/native/test/features/auth/login_page_test.dart +++ b/native/test/features/auth/login_page_test.dart @@ -6,8 +6,36 @@ import 'package:easynode_native/features/auth/auth_session.dart'; import 'package:easynode_native/features/auth/login_controller.dart'; import 'package:easynode_native/features/auth/login_page.dart'; import 'package:easynode_native/core/ui/app_color_theme.dart'; +import 'package:easynode_native/core/security/server_certificate_trust.dart'; +import 'package:easynode_native/core/utils/jwt_expiry.dart'; import 'package:easynode_native/l10n/app_localizations.dart'; +class _CertificateLoginController extends LoginController { + _CertificateLoginController(this.certificate) + : super( + apiClientFactory: (_, {String? token}) => + throw StateError('API client is not used by this UI test'), + ); + + final PresentedServerCertificate certificate; + + @override + Future login({ + required String serverAddress, + required String username, + required String password, + required String mfa2Token, + required bool httpRiskAccepted, + required bool savePassword, + LoginExpiry expiry = LoginExpiry.threeDays, + }) async => LoginResult(certificate: certificate); +} + +PresentedServerCertificate _certificate() => PresentedServerCertificate( + origin: 'https://100.74.175.1:8092', + fingerprint: List.filled(32, 'ab').join(), +); + void main() { Widget wrap(Widget child) => ProviderScope( child: MaterialApp( @@ -119,4 +147,32 @@ void main() { expect(loginPageShouldWarnHttp('http://10.0.0.1'), isTrue); expect(loginPageShouldWarnHttp('https://10.0.0.1'), isFalse); }); + + testWidgets('offers cancel and permanent trust for an invalid certificate', ( + tester, + ) async { + final certificate = _certificate(); + final controller = _CertificateLoginController(certificate); + await pumpLoginPage( + tester, + LoginPage( + controller: controller, + initialServerAddress: certificate.origin, + initialUsername: 'root', + initialSavePassword: false, + onLoginSuccess: (_) {}, + ), + ); + + await tester.ensureVisible(byKey(const Key('field-password'))); + await tester.enterText(byKey(const Key('field-password')), 'secret'); + await tester.tap(byKey(const Key('btn-login'))); + await tester.pumpAndSettle(); + + expect(find.text('无法验证服务器证书'), findsOneWidget); + expect(find.text('取消'), findsOneWidget); + expect(find.text('信任此证书'), findsOneWidget); + expect(find.text('仅本次继续'), findsNothing); + expect(find.text(certificate.displayFingerprint), findsOneWidget); + }); } diff --git a/server/app/config/index.js b/server/app/config/index.js index 4248f72..693e2ff 100644 --- a/server/app/config/index.js +++ b/server/app/config/index.js @@ -3,7 +3,7 @@ import path from 'node:path' const config = { httpPort: process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT) : 8082, httpsPort: process.env.HTTPS_PORT ? parseInt(process.env.HTTPS_PORT) : 8092, - enableHttps: process.env.ENABLE_HTTPS ? parseInt(process.env.ENABLE_HTTPS) : 0, // 0:关闭 1:自签证书 2:传入证书路径 + enableHttps: process.env.ENABLE_HTTPS ? parseInt(process.env.ENABLE_HTTPS) : 1, // 0:关闭 1:自签证书 2:传入证书路径 sslCertPath: process.env.SSL_CERT_PATH, sslKeyPath: process.env.SSL_KEY_PATH, uploadDir: path.join(process.cwd(),'app/db'), diff --git a/server/app/rdp-server.js b/server/app/rdp-server.js index 8ec1212..0ce713a 100644 --- a/server/app/rdp-server.js +++ b/server/app/rdp-server.js @@ -38,7 +38,7 @@ const startRdpServer = () => { }) rdpServer.listen(RDP_PORT, () => { - logger.info(`RDP服务运行在端口: ${ RDP_PORT }`) + logger.info('RDP服务运行正常') }) } catch (error) { logger.error('❌ RDP 初始化失败:', error.message) diff --git a/server/app/utils/ssl-cert.js b/server/app/utils/ssl-cert.js index ce1ef2f..2c829b5 100644 --- a/server/app/utils/ssl-cert.js +++ b/server/app/utils/ssl-cert.js @@ -1,10 +1,50 @@ import selfsigned from 'selfsigned' +import fs from 'node:fs' +import path from 'node:path' +import tls from 'node:tls' + +const SELF_SIGNED_CERT_FILE = 'https-selfsigned-cert.pem' +const SELF_SIGNED_KEY_FILE = 'https-selfsigned-key.pem' + +const writeFileAtomically = (targetPath, contents, mode) => { + const temporaryPath = `${ targetPath }.${ process.pid }.tmp` + fs.writeFileSync(temporaryPath, contents, { mode }) + fs.renameSync(temporaryPath, targetPath) +} + +const loadPersistedCertificate = (certPath, keyPath) => { + if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) return null + + try { + const cert = fs.readFileSync(certPath, 'utf8') + const key = fs.readFileSync(keyPath, 'utf8') + // Besides checking PEM syntax, this verifies that the certificate and + // private key belong to the same keypair. A partially written or manually + // damaged pair is regenerated instead of breaking HTTPS startup forever. + tls.createSecureContext({ cert, key }) + fs.chmodSync(keyPath, 0o600) + return { cert, key } + } catch (error) { + logger.warn(`持久化自签名证书无效,将重新生成: ${ error.message }`) + return null + } +} /** - * 生成自签名证书 + * 加载持久化的自签名证书;首次运行时生成并保存。 + * 默认保存在 app/db,Docker Compose 已持久化该目录。 * @returns {Object} 包含 cert 和 key 的对象 */ -function generateSelfSignedCert() { +function generateSelfSignedCert(storageDir = path.join(process.cwd(), 'app/db')) { + const certPath = path.join(storageDir, SELF_SIGNED_CERT_FILE) + const keyPath = path.join(storageDir, SELF_SIGNED_KEY_FILE) + + const persisted = loadPersistedCertificate(certPath, keyPath) + if (persisted) { + logger.info(`已加载持久化自签名证书: ${ certPath }`) + return persisted + } + const attrs = [{ name: 'commonName', value: 'localhost' }] const pems = selfsigned.generate(attrs, { keySize: 2048, @@ -13,22 +53,16 @@ function generateSelfSignedCert() { extensions: [ { name: 'basicConstraints', - cA: true + cA: false }, { name: 'keyUsage', - keyCertSign: true, digitalSignature: true, - nonRepudiation: true, - keyEncipherment: true, - dataEncipherment: true + keyEncipherment: true }, { name: 'extKeyUsage', - serverAuth: true, - clientAuth: true, - codeSigning: true, - timeStamping: true + serverAuth: true }, { name: 'subjectAltName', @@ -46,7 +80,10 @@ function generateSelfSignedCert() { ] }) - logger.info('已生成自签名证书') + fs.mkdirSync(storageDir, { recursive: true }) + writeFileAtomically(certPath, pems.cert, 0o644) + writeFileAtomically(keyPath, pems.private, 0o600) + logger.info(`已生成并持久化自签名证书: ${ certPath }`) return { cert: pems.cert, key: pems.private @@ -54,5 +91,7 @@ function generateSelfSignedCert() { } export { - generateSelfSignedCert + generateSelfSignedCert, + SELF_SIGNED_CERT_FILE, + SELF_SIGNED_KEY_FILE } diff --git a/server/package.json b/server/package.json index 250544e..49e500b 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ "lint": "eslint . --ext .js,.vue", "lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix", "test": "node test/test-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-rest-api-auth.js && node test/test-ws-comprehensive.js", - "test:security": "node test/test-sftp-cache-path.js && node test/test-rsync-command.js", + "test:security": "node test/test-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-ssl-cert-persistence.js", "test:api": "node test/test-rest-api-auth.js", "test:ws": "node test/test-ws-comprehensive.js", "test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js", diff --git a/server/test/test-ssl-cert-persistence.js b/server/test/test-ssl-cert-persistence.js new file mode 100644 index 0000000..17e6507 --- /dev/null +++ b/server/test/test-ssl-cert-persistence.js @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { X509Certificate } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +global.logger = { + info: () => {}, + warn: () => {}, + error: () => {} +} + +const { + generateSelfSignedCert, + SELF_SIGNED_CERT_FILE, + SELF_SIGNED_KEY_FILE +} = await import('../app/utils/ssl-cert.js') + +const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'easynode-ssl-')) + +try { + const first = generateSelfSignedCert(temporaryDir) + const second = generateSelfSignedCert(temporaryDir) + + assert.equal(second.cert, first.cert, 'certificate should be reused') + assert.equal(second.key, first.key, 'private key should be reused') + assert.equal( + new X509Certificate(first.cert).ca, + false, + 'HTTPS leaf certificate should not be a certificate authority' + ) + assert.equal( + fs.readFileSync(path.join(temporaryDir, SELF_SIGNED_CERT_FILE), 'utf8'), + first.cert + ) + assert.equal( + fs.readFileSync(path.join(temporaryDir, SELF_SIGNED_KEY_FILE), 'utf8'), + first.key + ) + assert.equal( + fs.statSync(path.join(temporaryDir, SELF_SIGNED_KEY_FILE)).mode & 0o777, + 0o600, + 'private key should only be readable by its owner' + ) + + fs.writeFileSync( + path.join(temporaryDir, SELF_SIGNED_KEY_FILE), + 'not a private key' + ) + const recovered = generateSelfSignedCert(temporaryDir) + assert.notEqual(recovered.cert, first.cert, 'invalid pair should be replaced') + assert.notEqual(recovered.key, 'not a private key') + assert.deepEqual(generateSelfSignedCert(temporaryDir), recovered) + + console.log('SSL certificate persistence tests passed') +} finally { + fs.rmSync(temporaryDir, { recursive: true, force: true }) +}