feat: mobile to native API

This commit is contained in:
chaos-zhu
2026-06-07 00:01:54 +08:00
parent 9cd393b99c
commit 995bb75b58
11 changed files with 85 additions and 56 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
# EasyNode Native
EasyNode 的 Flutter Native App,复用现有后端 (`/api/v1`),在手机上提供服务器列表、SSH 终端、SFTP 文件管理、脚本库、账户安全等能力。App 自身不打包后端地址,登录时由用户填写。
EasyNode 的 Flutter Native App,复用现有后端 (`/api/v1`),在原生端上提供服务器列表、SSH 终端、SFTP 文件管理、脚本库、账户安全等能力。App 自身不打包后端地址,登录时由用户填写。
## 技术栈
@@ -68,7 +68,7 @@ native/
- 登录密码:`core/crypto/rsa_crypto.dart#encryptPassword` → PKCS1 + utf8,对应服务端 `node-rsa.decrypt(ct, 'utf8')`。
- Native 端 SSH 临时密钥:32 字节 AES key → base64 → utf8 → RSA,对应 `RSADecryptAsync` + `Buffer.from(text, 'base64')`。
- `/mobile/ssh-connection` 返回的 `{ iv, tag, ciphertext }` 由 `core/crypto/aes_gcm_crypto.dart` AES-GCM 解密。
- `/native/ssh-connection` 返回的 `{ iv, tag, ciphertext }` 由 `core/crypto/aes_gcm_crypto.dart` AES-GCM 解密。
- **修改加密协议必须 server / native 同步升级**,否则破坏现有 App 兼容性。
### 登录流程
@@ -93,7 +93,7 @@ native/
- `features/terminal/ssh_terminal_controller.dart`:dartssh2 起 shell session,stdout/stderr 写入 `xterm.Terminal`;`terminal.onOutput` 把按键回送给 SSH session;支持 `Ctrl + 字母` 一次性修饰键。Shell 启动后主动 `resizeTerminal()` 一次,避免 PTY 卡在 80x24。
- `terminal_session_manager.dart`:所有终端会话集合 + 当前激活 id,提供 open / setActive / reconnect / close / closeAll。`reconnect` 复用现有 `Terminal` buffer,避免清屏。
- `ssh_connection_config.dart`:与服务端 `toMobileSshPayload` 对齐的纯数据类。
- `ssh_connection_config.dart`:与服务端 native SSH payload 对齐的纯数据类。
- `http_proxy_connector.dart` / `socks5_connector.dart` / `ssh_transport.dart`:代理与跳板机连接通道。
### 存储分层
@@ -196,5 +196,5 @@ iOS 构建需要 macOS + Xcode。
## 后端约定
- Native 端复用 `/api/v1` 全部接口,鉴权与 Web 端一致:`token` header + `session` cookie。
- 专属端点:`POST /api/v1/mobile/ssh-connection`,返回 AES-GCM 加密后的 SSH 连接参数。修改时同步更新 `server/app/controller/mobile.js` 与 `native/lib/features/servers/server_repository.dart`、`native/lib/core/crypto/aes_gcm_crypto.dart`。
- 专属端点:`POST /api/v1/native/ssh-connection`,返回 AES-GCM 加密后的 SSH 连接参数。修改时同步更新 `server/app/controller/native.js` 与 `native/lib/features/servers/server_repository.dart`、`native/lib/core/crypto/aes_gcm_crypto.dart`。
- 解密后的 SSH 凭据**不得写入磁盘或日志**。
+16 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/api/api_client.dart';
@@ -30,6 +31,7 @@ class _Bootstrap {
required this.secureStorage,
required this.cookieStore,
required this.flutterSecureStorage,
required this.appVersion,
required this.initialPassword,
required this.initialAuthState,
});
@@ -38,6 +40,7 @@ class _Bootstrap {
final SecureAppStorage secureStorage;
final SessionCookieStore cookieStore;
final FlutterSecureStorage flutterSecureStorage;
final String appVersion;
final String initialPassword;
final AuthState initialAuthState;
}
@@ -53,6 +56,10 @@ class EasyNodeApp extends StatelessWidget {
final secureWrapper = SecureAppStorage(secure);
final appStorage = AppStorage(prefs);
final cookieStore = SessionCookieStore(secureWrapper);
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.buildNumber.isEmpty
? packageInfo.version
: '${packageInfo.version}+${packageInfo.buildNumber}';
var initialPassword = '';
if (appStorage.savePassword) {
@@ -83,6 +90,7 @@ class EasyNodeApp extends StatelessWidget {
serverAddress: appStorage.serverAddress,
cookieStore: cookieStore,
token: token,
appVersion: appVersion,
);
try {
final pubKey = await api.getPublicKey();
@@ -109,6 +117,7 @@ class EasyNodeApp extends StatelessWidget {
secureStorage: secureWrapper,
cookieStore: cookieStore,
flutterSecureStorage: secure,
appVersion: appVersion,
initialPassword: initialPassword,
initialAuthState: initialAuthState,
),
@@ -126,15 +135,19 @@ class EasyNodeApp extends StatelessWidget {
(ref) => AuthNotifier(ref, _b.initialAuthState),
),
],
child: _AppRoot(initialPassword: _b.initialPassword),
child: _AppRoot(
initialPassword: _b.initialPassword,
appVersion: _b.appVersion,
),
);
}
}
class _AppRoot extends ConsumerStatefulWidget {
const _AppRoot({required this.initialPassword});
const _AppRoot({required this.initialPassword, required this.appVersion});
final String initialPassword;
final String appVersion;
@override
ConsumerState<_AppRoot> createState() => _AppRootState();
@@ -170,6 +183,7 @@ class _AppRootState extends ConsumerState<_AppRoot> {
cookieStore: ref.read(cookieStoreProvider),
token: token,
onUnauthorized: _signOutOnUnauthorized,
appVersion: widget.appVersion,
);
}
+17 -11
View File
@@ -6,28 +6,31 @@ import 'package:flutter/foundation.dart';
import 'api_result.dart';
import 'cookie_store.dart';
const String _mobileAppVersion = '0.1.0';
const String _fallbackNativeAppVersion = 'unknown';
String buildMobileUserAgent() {
String osName;
String buildNativeUserAgent({String? appVersion}) {
String clientName;
if (Platform.isAndroid) {
osName = 'Android';
clientName = 'Android';
} else if (Platform.isIOS) {
osName = 'iOS';
clientName = 'iOS';
} else if (Platform.isMacOS) {
osName = 'macOS';
clientName = 'macOS';
} else if (Platform.isWindows) {
osName = 'Windows';
clientName = 'Windows';
} else if (Platform.isLinux) {
osName = 'Linux';
clientName = 'Linux';
} else {
osName = 'Unknown';
clientName = 'Native';
}
final sanitizedVersion = Platform.operatingSystemVersion
.replaceAll('(', '[')
.replaceAll(')', ']')
.trim();
return 'EasyNode-Mobile/$_mobileAppVersion ($osName; $sanitizedVersion)';
final version = appVersion?.trim().isNotEmpty == true
? appVersion!.trim()
: _fallbackNativeAppVersion;
return 'EasyNode-$clientName/$version ($sanitizedVersion)';
}
class ApiClient {
@@ -36,6 +39,7 @@ class ApiClient {
required SessionCookieStore cookieStore,
String? token,
Future<void> Function(String? message)? onUnauthorized,
String? appVersion,
Dio? dio,
}) : _cookieStore = cookieStore,
_token = token,
@@ -47,7 +51,9 @@ class ApiClient {
baseUrl: '$serverAddress/api/v1',
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {'User-Agent': buildMobileUserAgent()},
headers: {
'User-Agent': buildNativeUserAgent(appVersion: appVersion),
},
),
) {
if (kDebugMode) {
+1 -1
View File
@@ -11,7 +11,7 @@ abstract class DeviceIdStore {
class SecureDeviceIdStore implements DeviceIdStore {
SecureDeviceIdStore(this._storage);
final FlutterSecureStorage _storage;
static const _key = 'mobileDeviceId';
static const _key = 'nativeDeviceId';
@override
Future<String?> read() => _storage.read(key: _key);
@@ -113,7 +113,7 @@ class ApiServerRepository implements ServerRepository {
///
/// 1. Generate a fresh 32-byte AES key.
/// 2. RSA-encrypt it with the public key fetched at login time.
/// 3. POST to `/mobile/ssh-connection`.
/// 3. POST to `/native/ssh-connection`.
/// 4. AES-GCM-decrypt the response envelope.
/// 5. Return a strongly typed [SshConnectionConfig].
///
@@ -122,7 +122,7 @@ class ApiServerRepository implements ServerRepository {
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
final keyBytes = _randomBytes(32);
final encryptedKey = _rsa.encryptTemporaryKey(_publicKeyPem, keyBytes);
final response = await _api.postJson('/mobile/ssh-connection', {
final response = await _api.postJson('/native/ssh-connection', {
'hostId': hostId,
'encryptedKey': encryptedKey,
});
@@ -30,8 +30,17 @@ class LoginSession {
final int createAt;
final int expireAt;
bool get isNativeClient =>
browser.contains('EasyNode-Mobile') || os.contains('EasyNode-Mobile');
bool get isNativeClient {
const prefixes = [
'EasyNode Android',
'EasyNode iOS',
'EasyNode macOS',
'EasyNode Windows',
'EasyNode Linux',
'EasyNode Native',
];
return prefixes.any((prefix) => browser.contains(prefix));
}
String get location {
final parts = <String>[
@@ -1,5 +1,5 @@
/// Plaintext SSH connection parameters returned by `/mobile/ssh-connection`
/// after AES-GCM decryption. Mirrors `toMobileSshPayload` on the server.
/// Plaintext SSH connection parameters returned by `/native/ssh-connection`
/// after AES-GCM decryption. Mirrors the native SSH payload on the server.
class SshAuthConfig {
const SshAuthConfig({
required this.hostId,
+1 -1
View File
@@ -17,7 +17,7 @@ final apiClientProvider = Provider<ApiClient>((ref) {
return api;
});
/// Repository for `/host-list` and `/mobile/ssh-connection`. Depends on the
/// Repository for `/host-list` and `/native/ssh-connection`. Depends on the
/// active ApiClient and the public key fetched at login time, both of
/// which are derived from [authProvider].
final serverRepositoryProvider = Provider<ServerRepository>((ref) {
@@ -3,7 +3,7 @@ const path = require('path')
const { RSADecryptAsync } = require('../utils/encrypt')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
function encryptJsonForMobile(payload, key) {
function encryptJsonForNative(payload, key) {
if (!Buffer.isBuffer(key) || key.length !== 32) {
throw new Error('temporary key must be 32 bytes')
}
@@ -21,10 +21,10 @@ function encryptJsonForMobile(payload, key) {
}
}
function normalizeMobileAuthPayload(hostId, name, authInfo = {}) {
function normalizeNativeAuthPayload(hostId, name, authInfo = {}) {
const { host, port, username, authType } = authInfo
if (!['password', 'privateKey'].includes(authType)) {
throw new Error(`unsupported mobile ssh auth type: ${ authType || 'empty' }`)
throw new Error(`unsupported native ssh auth type: ${ authType || 'empty' }`)
}
return {
@@ -40,7 +40,7 @@ function normalizeMobileAuthPayload(hostId, name, authInfo = {}) {
}
}
async function buildMobileTopology(hostInfo = {}) {
async function buildNativeTopology(hostInfo = {}) {
const { proxyType } = hostInfo
if (!['proxyServer', 'jumpHosts'].includes(proxyType)) {
return { proxyType: '', proxy: null, jumpHosts: [] }
@@ -48,14 +48,14 @@ async function buildMobileTopology(hostInfo = {}) {
let { getConnectionHelper } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (getConnectionHelper) {
const config = await getConnectionHelper(proxyType, hostInfo, normalizeMobileAuthPayload)
const config = await getConnectionHelper(proxyType, hostInfo, normalizeNativeAuthPayload)
return config
} else {
throw new Error('跳板机&代理服务为Plus功能')
}
}
async function getMobileSshConnection({ request, res }) {
async function getNativeSshConnection({ request, res }) {
try {
const { hostId, encryptedKey } = request.body || {}
if (!hostId || !encryptedKey) {
@@ -67,18 +67,18 @@ async function getMobileSshConnection({ request, res }) {
const { getConnectionOptions } = require('../socket/terminal')
const { authInfo, name, hostInfo } = await getConnectionOptions(hostId)
const payload = {
...normalizeMobileAuthPayload(hostId, name, authInfo),
...await buildMobileTopology(hostInfo)
...normalizeNativeAuthPayload(hostId, name, authInfo),
...await buildNativeTopology(hostInfo)
}
const data = encryptJsonForMobile(payload, tempKey)
const data = encryptJsonForNative(payload, tempKey)
return res.success({ data, msg: 'success' })
} catch (error) {
logger.error('getMobileSshConnection error:', error.message)
return res.fail({ msg: error.message || 'mobile ssh connection failed' })
logger.error('getNativeSshConnection error:', error.message)
return res.fail({ msg: error.message || 'native ssh connection failed' })
}
}
module.exports = {
getMobileSshConnection
getNativeSshConnection
}
+10 -10
View File
@@ -23,16 +23,16 @@ const getpublicKey = async ({ res }) => {
res.success({ data })
}
const parseLoginAgentInfo = (userAgent = '') => {
const mobileMatch = userAgent.match(/^EasyNode-Mobile\/(\S+)\s*(?:\(([^)]*)\))?/)
if (mobileMatch) {
const [, appVersion, parenContent = ''] = mobileMatch
const parts = parenContent.split(';').map(s => s.trim()).filter(Boolean)
return {
browser: { name: 'EasyNode Mobile', version: appVersion || '' },
os: { name: parts[0] || 'Mobile', version: parts.slice(1).join('; ') || '' }
}
}
const parseLoginAgentInfo = (userAgent = '') => {
const nativeMatch = userAgent.match(/^EasyNode-(Android|iOS|macOS|Windows|Linux|Native)\/(\S+)\s*(?:\(([^)]*)\))?/)
if (nativeMatch) {
const [, clientName, appVersion, parenContent = ''] = nativeMatch
const parts = parenContent.split(';').map(s => s.trim()).filter(Boolean)
return {
browser: { name: `EasyNode ${ clientName }`, version: appVersion || '' },
os: { name: clientName, version: parts.join('; ') || '' }
}
}
return uap(userAgent)
}
+9 -9
View File
@@ -13,7 +13,7 @@ const { getProxyList, addProxy, updateProxy, removeProxy } = require('../control
const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config')
const { getServerListConfig, saveServerListConfig } = require('../controller/server-list-config')
const { getSuspendedSessions, getTerminalSessionConfig, updateTerminalSessionConfig } = require('../controller/terminal')
const { getMobileSshConnection } = require('../controller/mobile')
const { getNativeSshConnection } = require('../controller/native')
const ssh = [
{
@@ -404,13 +404,13 @@ const terminal = [
}
]
const mobile = [
{
method: 'post',
path: '/mobile/ssh-connection',
controller: getMobileSshConnection
}
]
const native = [
{
method: 'post',
path: '/native/ssh-connection',
controller: getNativeSshConnection
}
]
const sftp = [
{
@@ -435,6 +435,6 @@ module.exports = [].concat(
terminalConfig,
serverListConfig,
terminal,
mobile,
native,
sftp
)