mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 14:48:03 +08:00
feat(mobile): 添加服务器模型与凭据仓库
- features/servers/server_model.dart: 仅保留移动端列表所需字段, fromJson 兼容 _id / 字符串 port / 缺失 tag;canConnect 处理 isConfig 与 expired。 - features/terminal/ssh_connection_config.dart: mobile/ssh-connection 解密后明文模型,与 toMobileSshPayload 对齐。 - features/servers/server_repository.dart: fetchHosts 拉取 host-list; fetchSshConfig 随机 32B key -> RSA 加密 -> POST mobile/ssh-connection -> AES-GCM 解密 -> 构造 SshConnectionConfig;临时 key 与明文仅在 方法内存留。 - server_model 单测覆盖正常 / fallback / 不可连接场景。
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/// Mobile-side projection of `/api/v1/host-list` items.
|
||||
///
|
||||
/// Only fields needed for the mobile list and connect action are kept;
|
||||
/// passwords, private keys, etc. are intentionally excluded — the server
|
||||
/// also clears them in its host-list response.
|
||||
class ServerModel {
|
||||
const ServerModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.authType,
|
||||
required this.group,
|
||||
required this.tag,
|
||||
required this.expired,
|
||||
required this.isConfig,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String username;
|
||||
final String authType;
|
||||
final String group;
|
||||
final List<String> tag;
|
||||
final bool expired;
|
||||
final bool isConfig;
|
||||
|
||||
factory ServerModel.fromJson(Map<String, dynamic> json) {
|
||||
final id = (json['id'] ?? json['_id'] ?? '').toString();
|
||||
final tagRaw = json['tag'];
|
||||
final List<String> tag;
|
||||
if (tagRaw is List) {
|
||||
tag = tagRaw.map((e) => e.toString()).toList(growable: false);
|
||||
} else {
|
||||
tag = const [];
|
||||
}
|
||||
final portRaw = json['port'];
|
||||
final int port;
|
||||
if (portRaw is int) {
|
||||
port = portRaw;
|
||||
} else if (portRaw is num) {
|
||||
port = portRaw.toInt();
|
||||
} else {
|
||||
port = int.tryParse(portRaw?.toString() ?? '') ?? 22;
|
||||
}
|
||||
return ServerModel(
|
||||
id: id,
|
||||
name: (json['name'] ?? '').toString(),
|
||||
host: (json['host'] ?? '').toString(),
|
||||
port: port,
|
||||
username: (json['username'] ?? '').toString(),
|
||||
authType: (json['authType'] ?? '').toString(),
|
||||
group: (json['group'] ?? '').toString(),
|
||||
tag: tag,
|
||||
expired: json['expired'] == true,
|
||||
isConfig: json['isConfig'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the connect button should be enabled. The server marks hosts
|
||||
/// without auth fields as `isConfig: false`; expired hosts are also not
|
||||
/// connectable in the first release.
|
||||
bool get canConnect => isConfig && !expired;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/api/api_result.dart';
|
||||
import '../../core/crypto/aes_gcm_crypto.dart';
|
||||
import '../../core/crypto/rsa_crypto.dart';
|
||||
import '../terminal/ssh_connection_config.dart';
|
||||
import 'server_model.dart';
|
||||
|
||||
/// Backs the server list page and the connect action.
|
||||
class ServerRepository {
|
||||
ServerRepository({
|
||||
required ApiClient apiClient,
|
||||
required String publicKeyPem,
|
||||
RsaCrypto? rsa,
|
||||
Random? random,
|
||||
}) : _api = apiClient,
|
||||
_publicKeyPem = publicKeyPem,
|
||||
_rsa = rsa ?? RsaCrypto(),
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final ApiClient _api;
|
||||
final String _publicKeyPem;
|
||||
final RsaCrypto _rsa;
|
||||
final Random _random;
|
||||
|
||||
/// Fetch the host list. Server returns `{ data: [host...] }`.
|
||||
Future<List<ServerModel>> fetchHosts() async {
|
||||
final response = await _api.getJson('/host-list');
|
||||
final raw = response['data'];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ServerModel.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Request decrypted SSH connection parameters for [hostId].
|
||||
///
|
||||
/// 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`.
|
||||
/// 4. AES-GCM-decrypt the response envelope.
|
||||
/// 5. Return a strongly typed [SshConnectionConfig].
|
||||
///
|
||||
/// The temporary key and decrypted payload live only inside this method.
|
||||
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
|
||||
final keyBytes = _randomBytes(32);
|
||||
final encryptedKey = _rsa.encryptTemporaryKey(_publicKeyPem, keyBytes);
|
||||
final response = await _api.postJson('/mobile/ssh-connection', {
|
||||
'hostId': hostId,
|
||||
'encryptedKey': encryptedKey,
|
||||
});
|
||||
if (response['status'] != 200) {
|
||||
throw ApiFailure(response['msg']?.toString() ?? '获取 SSH 连接参数失败');
|
||||
}
|
||||
final data = response['data'];
|
||||
if (data is! Map) {
|
||||
throw ApiFailure('SSH 连接响应格式异常');
|
||||
}
|
||||
final iv = data['iv'];
|
||||
final tag = data['tag'];
|
||||
final ciphertext = data['ciphertext'];
|
||||
if (iv is! String || tag is! String || ciphertext is! String) {
|
||||
throw ApiFailure('SSH 连接响应缺少字段');
|
||||
}
|
||||
|
||||
final Map<String, dynamic> plaintext;
|
||||
try {
|
||||
plaintext = decryptAesGcmJson(
|
||||
key: keyBytes,
|
||||
ivBase64: iv,
|
||||
tagBase64: tag,
|
||||
ciphertextBase64: ciphertext,
|
||||
);
|
||||
} catch (_) {
|
||||
throw ApiFailure('SSH 连接参数解密失败');
|
||||
}
|
||||
|
||||
return SshConnectionConfig.fromJson(plaintext);
|
||||
}
|
||||
|
||||
Uint8List _randomBytes(int length) {
|
||||
final bytes = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
bytes[i] = _random.nextInt(256);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Expose so [ServerRepository] can be JSON-serialized in widget tests.
|
||||
// ignore: unused_element
|
||||
String _debugBase64Bytes(Uint8List bytes) => base64Encode(bytes);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Plaintext SSH connection parameters returned by `/mobile/ssh-connection`
|
||||
/// after AES-GCM decryption. Mirrors `toMobileSshPayload` on the server.
|
||||
class SshConnectionConfig {
|
||||
const SshConnectionConfig({
|
||||
required this.hostId,
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.authType,
|
||||
required this.password,
|
||||
required this.privateKey,
|
||||
required this.passphrase,
|
||||
});
|
||||
|
||||
final String hostId;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String username;
|
||||
|
||||
/// Either `password` or `privateKey`.
|
||||
final String authType;
|
||||
|
||||
/// Only populated when [authType] is `password`.
|
||||
final String password;
|
||||
|
||||
/// Only populated when [authType] is `privateKey`.
|
||||
final String privateKey;
|
||||
|
||||
/// Optional passphrase for an encrypted private key.
|
||||
final String passphrase;
|
||||
|
||||
factory SshConnectionConfig.fromJson(Map<String, dynamic> json) {
|
||||
final portRaw = json['port'];
|
||||
final int port;
|
||||
if (portRaw is int) {
|
||||
port = portRaw;
|
||||
} else if (portRaw is num) {
|
||||
port = portRaw.toInt();
|
||||
} else {
|
||||
port = int.tryParse(portRaw?.toString() ?? '') ?? 22;
|
||||
}
|
||||
return SshConnectionConfig(
|
||||
hostId: (json['hostId'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
host: (json['host'] ?? '').toString(),
|
||||
port: port,
|
||||
username: (json['username'] ?? '').toString(),
|
||||
authType: (json['authType'] ?? '').toString(),
|
||||
password: (json['password'] ?? '').toString(),
|
||||
privateKey: (json['privateKey'] ?? '').toString(),
|
||||
passphrase: (json['passphrase'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mobile/features/servers/server_model.dart';
|
||||
|
||||
void main() {
|
||||
test('parses host-list entry into ServerModel', () {
|
||||
final model = ServerModel.fromJson({
|
||||
'id': 'h1',
|
||||
'name': 'prod',
|
||||
'host': '10.0.0.2',
|
||||
'port': 22,
|
||||
'username': 'root',
|
||||
'authType': 'password',
|
||||
'group': 'g1',
|
||||
'tag': ['a', 'b'],
|
||||
'expired': false,
|
||||
'isConfig': true,
|
||||
});
|
||||
|
||||
expect(model.id, 'h1');
|
||||
expect(model.host, '10.0.0.2');
|
||||
expect(model.port, 22);
|
||||
expect(model.tag, ['a', 'b']);
|
||||
expect(model.canConnect, isTrue);
|
||||
});
|
||||
|
||||
test('falls back to _id and default port', () {
|
||||
final model = ServerModel.fromJson({
|
||||
'_id': 'h2',
|
||||
'name': 'fallback',
|
||||
'host': '10.0.0.3',
|
||||
'username': 'root',
|
||||
'authType': 'credential',
|
||||
'group': '',
|
||||
'expired': false,
|
||||
'isConfig': true,
|
||||
});
|
||||
|
||||
expect(model.id, 'h2');
|
||||
expect(model.port, 22);
|
||||
expect(model.tag, isEmpty);
|
||||
});
|
||||
|
||||
test('canConnect is false when expired or not configured', () {
|
||||
final expired = ServerModel.fromJson({
|
||||
'id': 'h3',
|
||||
'name': 'old',
|
||||
'host': '10.0.0.4',
|
||||
'port': 22,
|
||||
'username': 'root',
|
||||
'authType': 'password',
|
||||
'group': '',
|
||||
'tag': const [],
|
||||
'expired': true,
|
||||
'isConfig': true,
|
||||
});
|
||||
final unconfigured = ServerModel.fromJson({
|
||||
'id': 'h4',
|
||||
'name': 'broken',
|
||||
'host': '10.0.0.5',
|
||||
'port': 22,
|
||||
'username': 'root',
|
||||
'authType': '',
|
||||
'group': '',
|
||||
'tag': const [],
|
||||
'expired': false,
|
||||
'isConfig': false,
|
||||
});
|
||||
|
||||
expect(expired.canConnect, isFalse);
|
||||
expect(unconfigured.canConnect, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user