Skip to content

Commit 09431a0

Browse files
authored
fix(pve): Fix connection issues and add more error handlings (#1081)
* feat(PVE): Added display of PVE connection loading steps Added a detailed display of loading steps during the PVE connection process, including stages such as establishing an SSH tunnel, authentication, and data retrieval Also optimized the sorting of PVE storage content and the logic for handling connection errors * feat(pve): Added error handling and prompts for PVE two-factor authentication Added error handling for PVE servers when two-factor authentication is enabled, along with relevant error types and localized prompts * feat(PVE): Added support for PVE passwords during key-based authentication - Added the `pvePwd` field to the `ServerCustom` model - Added a PVE password input field to the edit page (displayed only during key-based authentication) - Updated multilingual files to support PVE-related loading states and password prompts - Optimized PVE connection logic to support password verification during key-based authentication
1 parent 2f67938 commit 09431a0

37 files changed

Lines changed: 641 additions & 139 deletions

lib/data/model/app/error.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class WebdavErr extends Err<WebdavErrType> {
5656
String? get solution => null;
5757
}
5858

59-
enum PveErrType { unknown, net, loginFailed }
59+
enum PveErrType { unknown, net, loginFailed, needTfa }
6060

6161
class PveErr extends Err<PveErrType> {
6262
const PveErr({required super.type, super.message});

lib/data/model/server/custom.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ final class ServerCustom {
1111

1212
final bool pveIgnoreCert;
1313

14+
final String? pvePwd;
15+
1416
/// {"title": "cmd"}
1517
final Map<String, String>? cmds;
1618

@@ -28,6 +30,7 @@ final class ServerCustom {
2830
//this.temperature,
2931
this.pveAddr,
3032
this.pveIgnoreCert = false,
33+
this.pvePwd,
3134
this.cmds,
3235
this.preferTempDev,
3336
this.logoUrl,
@@ -45,6 +48,7 @@ final class ServerCustom {
4548
//other.temperature == temperature &&
4649
other.pveAddr == pveAddr &&
4750
other.pveIgnoreCert == pveIgnoreCert &&
51+
other.pvePwd == pvePwd &&
4852
other.cmds == cmds &&
4953
other.preferTempDev == preferTempDev &&
5054
other.logoUrl == logoUrl &&
@@ -57,6 +61,7 @@ final class ServerCustom {
5761
//temperature.hashCode ^
5862
pveAddr.hashCode ^
5963
pveIgnoreCert.hashCode ^
64+
pvePwd.hashCode ^
6065
cmds.hashCode ^
6166
preferTempDev.hashCode ^
6267
logoUrl.hashCode ^

lib/data/model/server/custom.g.dart

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/data/model/server/pve.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,14 +293,18 @@ final class PveStorage extends PveResIface implements PveCtrlIface {
293293
});
294294

295295
static PveStorage fromJson(Map<String, dynamic> json) {
296+
final rawContent = json['content'] as String?;
297+
final contentParts = rawContent?.split(',');
298+
contentParts?.sort();
299+
final content = contentParts?.join(',') ?? rawContent ?? '';
296300
return PveStorage(
297301
id: json['id'],
298302
type: PveResType.storage,
299303
storage: json['storage'],
300304
node: json['node'],
301305
status: json['status'],
302306
plugintype: json['plugintype'],
303-
content: json['content'],
307+
content: content,
304308
shared: json['shared'],
305309
disk: json['disk'],
306310
maxdisk: json['maxdisk'],

lib/data/provider/container.g.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/data/provider/pve.dart

Lines changed: 99 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import 'package:dio/io.dart';
88
import 'package:fl_lib/fl_lib.dart';
99
import 'package:freezed_annotation/freezed_annotation.dart';
1010
import 'package:riverpod_annotation/riverpod_annotation.dart';
11-
import 'package:server_box/core/extension/context/locale.dart';
1211
import 'package:server_box/data/model/app/error.dart';
1312
import 'package:server_box/data/model/server/pve.dart';
1413
import 'package:server_box/data/model/server/server_private_info.dart';
@@ -19,6 +18,13 @@ part 'pve.g.dart';
1918

2019
typedef PveCtrlFunc = Future<bool> Function(String node, String id);
2120

21+
enum PveLoadingStep {
22+
none,
23+
forwarding,
24+
loggingIn,
25+
fetchingData,
26+
}
27+
2228
@freezed
2329
abstract class PveState with _$PveState {
2430
const factory PveState({
@@ -27,43 +33,51 @@ abstract class PveState with _$PveState {
2733
@Default(null) String? release,
2834
@Default(false) bool isBusy,
2935
@Default(false) bool isConnected,
36+
@Default(PveLoadingStep.none) PveLoadingStep loadingStep,
3037
}) = _PveState;
3138
}
3239

3340
@riverpod
3441
class PveNotifier extends _$PveNotifier {
35-
late final Spi spi;
36-
late String addr;
37-
late final SSHClient _client;
38-
late final ServerSocket _serverSocket;
42+
String? addr;
43+
ServerSocket? _serverSocket;
3944
final List<SSHForwardChannel> _forwards = [];
4045
int _localPort = 0;
41-
late final Dio session;
42-
late final bool _ignoreCert;
46+
Dio? _session;
47+
bool _ignoreCert = false;
4348

44-
@override
45-
PveState build(Spi spiParam) {
46-
spi = spiParam;
47-
final serverState = ref.watch(serverProvider(spi.id));
49+
Dio get session => _session!;
50+
String get addrValue => addr!;
51+
52+
SSHClient get _client {
53+
final serverState = ref.read(serverProvider(spiParam.id));
4854
final client = serverState.client;
4955
if (client == null) {
50-
return const PveState(error: PveErr(type: PveErrType.net, message: 'Server client is null'));
56+
throw PveErr(type: PveErrType.net, message: 'Server client is null');
5157
}
52-
_client = client;
53-
final addr = spi.custom?.pveAddr;
54-
if (addr == null) {
58+
return client;
59+
}
60+
61+
@override
62+
PveState build(Spi spiParam) {
63+
ref.onDispose(() => dispose());
64+
final serverState = ref.watch(serverProvider(spiParam.id));
65+
if (serverState.client == null) {
66+
return const PveState(loadingStep: PveLoadingStep.forwarding);
67+
}
68+
final pveAddr = spiParam.custom?.pveAddr;
69+
if (pveAddr == null) {
5570
return PveState(error: PveErr(type: PveErrType.net, message: 'PVE address is null'));
5671
}
57-
this.addr = addr;
58-
_ignoreCert = spi.custom?.pveIgnoreCert ?? false;
72+
addr = pveAddr;
73+
_ignoreCert = spiParam.custom?.pveIgnoreCert ?? false;
5974
_initSession();
60-
// Async initialization
6175
Future.microtask(() => _init());
62-
return const PveState();
76+
return const PveState(loadingStep: PveLoadingStep.forwarding);
6377
}
6478

6579
void _initSession() {
66-
session = Dio()
80+
_session = Dio()
6781
..httpClientAdapter = IOHttpClientAdapter(
6882
createHttpClient: () {
6983
final client = HttpClient();
@@ -79,35 +93,53 @@ class PveNotifier extends _$PveNotifier {
7993

8094
bool get onlyOneNode => state.data?.nodes.length == 1;
8195

96+
Future<void> reconnect() async {
97+
state = state.copyWith(error: null, isConnected: false, loadingStep: PveLoadingStep.forwarding);
98+
await _init();
99+
}
100+
82101
Future<void> _init() async {
83102
try {
103+
if (!ref.mounted) return;
104+
state = state.copyWith(loadingStep: PveLoadingStep.forwarding);
84105
await _forward();
106+
if (!ref.mounted) return;
107+
state = state.copyWith(loadingStep: PveLoadingStep.loggingIn);
85108
await _login();
109+
if (!ref.mounted) return;
110+
state = state.copyWith(loadingStep: PveLoadingStep.fetchingData);
86111
await _getRelease();
112+
if (!ref.mounted) return;
87113
state = state.copyWith(isConnected: true);
88-
} on PveErr {
89-
state = state.copyWith(error: PveErr(type: PveErrType.loginFailed, message: l10n.pveLoginFailed));
114+
await list();
115+
if (!ref.mounted) return;
116+
state = state.copyWith(loadingStep: PveLoadingStep.none);
117+
} on PveErr catch (e) {
118+
if (!ref.mounted) return;
119+
state = state.copyWith(error: e, loadingStep: PveLoadingStep.none);
90120
} catch (e, s) {
121+
if (!ref.mounted) return;
91122
Loggers.app.warning('PVE init failed', e, s);
92-
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
123+
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()), loadingStep: PveLoadingStep.none);
93124
}
94125
}
95126

96127
Future<void> _forward() async {
97-
final url = Uri.parse(addr);
128+
final url = Uri.parse(addrValue);
98129
if (_localPort == 0) {
99130
_serverSocket = await ServerSocket.bind('localhost', 0);
100-
_localPort = _serverSocket.port;
101-
_serverSocket.listen((socket) async {
102-
final forward = await _client.forwardLocal(url.host, url.port);
103-
_forwards.add(forward);
104-
forward.stream.cast<List<int>>().pipe(socket);
105-
socket.cast<List<int>>().pipe(forward.sink);
131+
_localPort = _serverSocket!.port;
132+
_serverSocket!.listen((socket) async {
133+
try {
134+
final forward = await _client.forwardLocal(url.host, url.port);
135+
_forwards.add(forward);
136+
forward.stream.cast<List<int>>().pipe(socket);
137+
socket.cast<List<int>>().pipe(forward.sink);
138+
} catch (e, s) {
139+
Loggers.app.warning('PVE forward failed', e, s);
140+
socket.destroy();
141+
}
106142
});
107-
final newUrl = Uri.parse(
108-
addr,
109-
).replace(host: 'localhost', port: _localPort).toString();
110-
dprint('Forwarding $newUrl to $addr');
111143
}
112144
}
113145

@@ -116,15 +148,6 @@ class PveNotifier extends _$PveNotifier {
116148
String? proxyHost,
117149
int? proxyPort,
118150
) async {
119-
/* final serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
120-
final _localPort = serverSocket.port;
121-
serverSocket.listen((socket) async {
122-
final forward = await _client.forwardLocal(url.host, url.port);
123-
forwards.add(forward);
124-
forward.stream.cast<List<int>>().pipe(socket);
125-
socket.cast<List<int>>().pipe(forward.sink);
126-
});*/
127-
128151
if (url.isScheme('https')) {
129152
return SecureSocket.startConnect(
130153
'localhost',
@@ -137,33 +160,43 @@ class PveNotifier extends _$PveNotifier {
137160
}
138161

139162
Future<void> _login() async {
163+
final useKeyAuth = spiParam.keyId != null;
164+
final password = useKeyAuth ? spiParam.custom?.pvePwd : spiParam.pwd;
165+
if (password == null) {
166+
throw PveErr(type: PveErrType.loginFailed, message: 'PVE password is required. Please set it in server settings.');
167+
}
140168
final resp = await session.post(
141-
'$addr/api2/extjs/access/ticket',
169+
'$addrValue/api2/extjs/access/ticket',
142170
data: {
143-
'username': spi.user,
144-
'password': spi.pwd,
171+
'username': spiParam.user,
172+
'password': password,
145173
'realm': 'pam',
146174
'new-format': '1',
147175
},
148176
options: Options(
149177
headers: {HttpHeaders.contentTypeHeader: Headers.jsonContentType},
150178
),
151179
);
152-
try {
153-
final ticket = resp.data['data']['ticket'];
154-
session.options.headers['CSRFPreventionToken'] =
155-
resp.data['data']['CSRFPreventionToken'];
156-
session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket';
157-
} catch (e) {
158-
throw PveErr(type: PveErrType.loginFailed, message: e.toString());
180+
181+
final data = resp.data['data'];
182+
if (data['NeedTFA'] == 1) {
183+
throw PveErr(type: PveErrType.needTfa, message: 'Two-factor authentication is not supported yet. Please disable OTP on the PVE server and try again.');
159184
}
185+
186+
_setAuthHeaders(data);
187+
}
188+
189+
void _setAuthHeaders(Map<String, dynamic> data) {
190+
final ticket = data['ticket'];
191+
session.options.headers['CSRFPreventionToken'] = data['CSRFPreventionToken'];
192+
session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket';
160193
}
161194

162195
/// Returns true if the PVE version is 8.0 or later
163196
Future<void> _getRelease() async {
164-
final resp = await session.get('$addr/api2/extjs/version');
197+
final resp = await session.get('$addrValue/api2/extjs/version');
165198
final version = resp.data['data']['release'] as String?;
166-
if (version != null) {
199+
if (version != null && ref.mounted) {
167200
state = state.copyWith(release: version);
168201
}
169202
}
@@ -172,25 +205,29 @@ class PveNotifier extends _$PveNotifier {
172205
if (!state.isConnected || state.isBusy) return;
173206
state = state.copyWith(isBusy: true);
174207
try {
175-
final resp = await session.get('$addr/api2/json/cluster/resources');
208+
final resp = await session.get('$addrValue/api2/json/cluster/resources');
176209
final res = resp.data['data'] as List;
177210
final result = await Computer.shared.start(PveRes.parse, (
178211
res,
179212
state.data,
180213
));
214+
if (!ref.mounted) return;
181215
state = state.copyWith(data: result, error: null);
182216
} catch (e) {
217+
if (!ref.mounted) return;
183218
Loggers.app.warning('PVE list failed', e);
184219
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
185220
} finally {
186-
state = state.copyWith(isBusy: false);
221+
if (ref.mounted) {
222+
state = state.copyWith(isBusy: false);
223+
}
187224
}
188225
}
189226

190227
Future<bool> reboot(String node, String id) async {
191228
if (!state.isConnected) return false;
192229
final resp = await session.post(
193-
'$addr/api2/json/nodes/$node/$id/status/reboot',
230+
'$addrValue/api2/json/nodes/$node/$id/status/reboot',
194231
);
195232
final success = _isCtrlSuc(resp);
196233
if (success) await list(); // Refresh data
@@ -200,7 +237,7 @@ class PveNotifier extends _$PveNotifier {
200237
Future<bool> start(String node, String id) async {
201238
if (!state.isConnected) return false;
202239
final resp = await session.post(
203-
'$addr/api2/json/nodes/$node/$id/status/start',
240+
'$addrValue/api2/json/nodes/$node/$id/status/start',
204241
);
205242
final success = _isCtrlSuc(resp);
206243
if (success) await list(); // Refresh data
@@ -210,7 +247,7 @@ class PveNotifier extends _$PveNotifier {
210247
Future<bool> stop(String node, String id) async {
211248
if (!state.isConnected) return false;
212249
final resp = await session.post(
213-
'$addr/api2/json/nodes/$node/$id/status/stop',
250+
'$addrValue/api2/json/nodes/$node/$id/status/stop',
214251
);
215252
final success = _isCtrlSuc(resp);
216253
if (success) await list(); // Refresh data
@@ -220,7 +257,7 @@ class PveNotifier extends _$PveNotifier {
220257
Future<bool> shutdown(String node, String id) async {
221258
if (!state.isConnected) return false;
222259
final resp = await session.post(
223-
'$addr/api2/json/nodes/$node/$id/status/shutdown',
260+
'$addrValue/api2/json/nodes/$node/$id/status/shutdown',
224261
);
225262
final success = _isCtrlSuc(resp);
226263
if (success) await list(); // Refresh data
@@ -233,7 +270,7 @@ class PveNotifier extends _$PveNotifier {
233270

234271
Future<void> dispose() async {
235272
try {
236-
await _serverSocket.close();
273+
await _serverSocket?.close();
237274
} catch (e, s) {
238275
Loggers.app.warning('Failed to close server socket', e, s);
239276
}

0 commit comments

Comments
 (0)