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