mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 17:08:29 +08:00
feat: mobile to native API
This commit is contained in:
+4
-4
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user