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:
chaos-zhu
2026-05-16 17:09:19 +08:00
parent 5577fa91f6
commit 1d779a082e
4 changed files with 291 additions and 0 deletions
@@ -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);
});
}