From 8da7341ccbae89f21d770fbf6b0d2ecc865c8968 Mon Sep 17 00:00:00 2001 From: NaiJi Date: Tue, 21 Feb 2023 13:11:04 +0400 Subject: [PATCH] chore: Implement basics of hetzner installation logic --- assets/translations/en.json | 3 +- .../server_providers/hetzner/hetzner_api.dart | 89 ++++++--- .../server_installation_cubit.dart | 4 +- .../models/callback_dialogue_branching.dart | 21 ++ .../models/launch_installation_data.dart | 19 ++ lib/logic/providers/server_provider.dart | 2 +- .../providers/server_providers/hetzner.dart | 181 +++++++++++++++++- 7 files changed, 287 insertions(+), 32 deletions(-) create mode 100644 lib/logic/models/callback_dialogue_branching.dart create mode 100644 lib/logic/models/launch_installation_data.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 6aee165c..ca95a6d0 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -429,6 +429,7 @@ "modals": { "dns_removal_error": "Couldn't remove DNS records.", "server_deletion_error": "Couldn't delete active server.", + "volume_creation_error": "Couldn't create volume.", "server_validators_error": "Couldn't fetch available servers.", "already_exists": "Such server already exists.", "unexpected_error": "Unexpected error during placement from the provider side.", @@ -479,4 +480,4 @@ "length_not_equal": "Length is [], should be {}", "length_longer": "Length is [], should be shorter than or equal to {}" } -} +} \ No newline at end of file diff --git a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart index f43fc050..fa244393 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart @@ -340,34 +340,72 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi { return success; } - Future> createServer({ + Future createServer({ required final String dnsApiToken, + required final String dnsProviderType, + required final String serverApiToken, required final User rootUser, + required final String base64Password, + required final String databasePassword, required final String domainName, + required final String hostName, + required final int volumeId, required final String serverType, - required final DnsProviderType dnsProvider, }) async { - final GenericResult newVolumeResponse = await createVolume(); + final String stagingAcme = StagingOptions.stagingAcme ? 'true' : 'false'; - if (!newVolumeResponse.success || newVolumeResponse.data == null) { - return GenericResult( - data: null, - success: false, - message: newVolumeResponse.message, - code: newVolumeResponse.code, - ); + Response? serverCreateResponse; + DioError? hetznerError; + bool success = false; + final Dio client = await getClient(); + try { + final Map data = { + 'name': hostName, + 'server_type': serverType, + 'start_after_create': false, + 'image': 'ubuntu-20.04', + 'volumes': [volumeId], + 'networks': [], + 'user_data': '#cloud-config\n' + 'runcmd:\n' + '- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/providers/hetzner/nixos-infect | ' + "STAGING_ACME='$stagingAcme' PROVIDER=$infectProviderName DNS_PROVIDER_TYPE=$dnsProviderType " + "NIX_CHANNEL=nixos-21.05 DOMAIN='$domainName' LUSER='${rootUser.login}' ENCODED_PASSWORD='$base64Password' " + 'CF_TOKEN=$dnsApiToken DB_PASSWORD=$databasePassword API_TOKEN=$serverApiToken HOSTNAME=$hostName bash 2>&1 | ' + 'tee /tmp/infect.log', + 'labels': {}, + 'automount': true, + 'location': region!, + }; + print('Decoded data: $data'); + + serverCreateResponse = await client.post('/servers', data: data); + success = true; + } on DioError catch (e) { + print(e); + hetznerError = e; + } catch (e) { + print(e); + } finally { + close(client); } - return createServerWithVolume( - dnsApiToken: dnsApiToken, - rootUser: rootUser, - domainName: domainName, - volume: newVolumeResponse.data!, - serverType: serverType, - dnsProvider: dnsProvider, + + String? apiResultMessage = serverCreateResponse?.statusMessage; + if (hetznerError != null && + hetznerError.response!.data['error']['code'] == 'uniqueness_error') { + apiResultMessage = 'uniqueness_error'; + } + + return GenericResult( + data: serverCreateResponse?.data, + success: success && hetznerError == null, + code: serverCreateResponse?.statusCode ?? + hetznerError?.response?.statusCode, + message: apiResultMessage, ); } - Future> createServerWithVolume({ + Future> skldfjalkdsjflkasd({ required final String dnsApiToken, required final User rootUser, required final String domainName, @@ -375,8 +413,6 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi { required final String serverType, required final DnsProviderType dnsProvider, }) async { - final Dio client = await getClient(); - final String dbPassword = StringGenerators.dbPassword(); final int volumeId = volume.id; @@ -388,14 +424,11 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi { base64.encode(utf8.encode(rootUser.password ?? 'PASS')); final String dnsProviderType = dnsProviderToInfectName(dnsProvider); - final String userdataString = - "#cloud-config\nruncmd:\n- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/$infectBranch/nixos-infect | STAGING_ACME='$stagingAcme' PROVIDER=$infectProviderName DNS_PROVIDER_TYPE=$dnsProviderType NIX_CHANNEL=nixos-21.05 DOMAIN='$domainName' LUSER='${rootUser.login}' ENCODED_PASSWORD='$base64Password' CF_TOKEN=$dnsApiToken DB_PASSWORD=$dbPassword API_TOKEN=$apiToken HOSTNAME=$hostname bash 2>&1 | tee /tmp/infect.log"; - Response? serverCreateResponse; ServerHostingDetails? serverDetails; DioError? hetznerError; bool success = false; - + final Dio client = await getClient(); try { final Map data = { 'name': hostname, @@ -404,7 +437,13 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi { 'image': 'ubuntu-20.04', 'volumes': [volumeId], 'networks': [], - 'user_data': userdataString, + 'user_data': '#cloud-config\n' + 'runcmd:\n' + '- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/$infectBranch/nixos-infect | ' + "STAGING_ACME='$stagingAcme' PROVIDER=$infectProviderName DNS_PROVIDER_TYPE=$dnsProviderType " + "NIX_CHANNEL=nixos-21.05 DOMAIN='$domainName' LUSER='${rootUser.login}' ENCODED_PASSWORD='$base64Password' " + 'CF_TOKEN=$dnsApiToken DB_PASSWORD=$dbPassword API_TOKEN=$apiToken HOSTNAME=$hostname bash 2>&1 | ' + 'tee /tmp/infect.log', 'labels': {}, 'automount': true, 'location': region!, diff --git a/lib/logic/cubit/server_installation/server_installation_cubit.dart b/lib/logic/cubit/server_installation/server_installation_cubit.dart index 17d90764..9758c545 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -9,8 +9,6 @@ import 'package:selfprivacy/logic/api_maps/rest_maps/api_controller.dart'; import 'package:selfprivacy/logic/providers/provider_settings.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/dns_providers/dns_provider_api_settings.dart'; import 'package:selfprivacy/logic/providers/providers_controller.dart'; -import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/server_provider.dart'; -import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/server_provider_api_settings.dart'; import 'package:selfprivacy/logic/models/hive/backblaze_credential.dart'; import 'package:selfprivacy/logic/models/hive/server_details.dart'; import 'package:selfprivacy/logic/models/hive/server_domain.dart'; @@ -174,7 +172,7 @@ class ServerInstallationCubit extends Cubit { await repository.saveServerType(serverType); await ProvidersController.currentServerProvider! - .trySetServerType(serverType); + .trySetServerLocation(serverType); emit( (state as ServerInstallationNotFinished).copyWith( diff --git a/lib/logic/models/callback_dialogue_branching.dart b/lib/logic/models/callback_dialogue_branching.dart new file mode 100644 index 00000000..614a7c22 --- /dev/null +++ b/lib/logic/models/callback_dialogue_branching.dart @@ -0,0 +1,21 @@ +import 'package:selfprivacy/logic/api_maps/generic_result.dart'; + +class CallbackDialogueBranching { + CallbackDialogueBranching({ + required this.title, + required this.description, + required this.choices, + }); + final String title; + final String description; + final List choices; +} + +class CallbackDialogueChoice { + CallbackDialogueChoice({ + required this.title, + required this.callback, + }); + final String title; + final Future> Function()? callback; +} diff --git a/lib/logic/models/launch_installation_data.dart b/lib/logic/models/launch_installation_data.dart new file mode 100644 index 00000000..14e1e592 --- /dev/null +++ b/lib/logic/models/launch_installation_data.dart @@ -0,0 +1,19 @@ +import 'package:selfprivacy/logic/models/hive/server_domain.dart'; +import 'package:selfprivacy/logic/models/hive/user.dart'; +import 'package:selfprivacy/logic/models/server_type.dart'; + +class LaunchInstallationData { + LaunchInstallationData({ + required this.rootUser, + required this.dnsApiToken, + required this.dnsProviderType, + required this.domainName, + required this.serverType, + }); + + final User rootUser; + final String dnsApiToken; + final String domainName; + final DnsProviderType dnsProviderType; + final ServerType serverType; +} diff --git a/lib/logic/providers/server_provider.dart b/lib/logic/providers/server_provider.dart index e5c1c1bf..642c779a 100644 --- a/lib/logic/providers/server_provider.dart +++ b/lib/logic/providers/server_provider.dart @@ -5,7 +5,7 @@ import 'package:selfprivacy/logic/models/server_type.dart'; export 'package:selfprivacy/logic/api_maps/generic_result.dart'; abstract class ServerProvider { - Future> trySetServerType(final ServerType type); + Future> trySetServerLocation(final String location); Future> tryInitApiByToken(final String token); Future>> getAvailableLocations(); Future>> getServerTypes({ diff --git a/lib/logic/providers/server_providers/hetzner.dart b/lib/logic/providers/server_providers/hetzner.dart index fdc8f11e..f1453448 100644 --- a/lib/logic/providers/server_providers/hetzner.dart +++ b/lib/logic/providers/server_providers/hetzner.dart @@ -1,8 +1,14 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart'; +import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; +import 'package:selfprivacy/logic/models/callback_dialogue_branching.dart'; import 'package:selfprivacy/logic/models/disk_size.dart'; import 'package:selfprivacy/logic/models/hive/server_details.dart'; import 'package:selfprivacy/logic/models/hive/server_domain.dart'; import 'package:selfprivacy/logic/models/json/hetzner_server_info.dart'; +import 'package:selfprivacy/logic/models/launch_installation_data.dart'; import 'package:selfprivacy/logic/models/metrics.dart'; import 'package:selfprivacy/logic/models/price.dart'; import 'package:selfprivacy/logic/models/server_basic_info.dart'; @@ -11,6 +17,8 @@ import 'package:selfprivacy/logic/models/server_provider_location.dart'; import 'package:selfprivacy/logic/models/server_type.dart'; import 'package:selfprivacy/logic/providers/server_provider.dart'; import 'package:selfprivacy/utils/extensions/string_extensions.dart'; +import 'package:selfprivacy/utils/network_utils.dart'; +import 'package:selfprivacy/utils/password_generator.dart'; class ApiAdapter { ApiAdapter({final String? region, final bool isWithToken = true}) @@ -42,7 +50,9 @@ class HetznerServerProvider extends ServerProvider { ApiAdapter _adapter; @override - Future> trySetServerType(final ServerType type) async { + Future> trySetServerLocation( + final String location, + ) async { final bool apiInitialized = _adapter.api().isWithToken; if (!apiInitialized) { return GenericResult( @@ -54,7 +64,7 @@ class HetznerServerProvider extends ServerProvider { _adapter = ApiAdapter( isWithToken: true, - region: type.location.identifier, + region: location, ); return success; } @@ -302,6 +312,7 @@ class HetznerServerProvider extends ServerProvider { end, 'cpu', ); + if (cpuResult.data.isEmpty || !cpuResult.success) { return GenericResult( success: false, @@ -387,4 +398,170 @@ class HetznerServerProvider extends ServerProvider { data: timestamp, ); } + + String dnsProviderToInfectName(final DnsProviderType dnsProvider) { + String dnsProviderType; + switch (dnsProvider) { + case DnsProviderType.digitalOcean: + dnsProviderType = 'DIGITALOCEAN'; + break; + case DnsProviderType.cloudflare: + default: + dnsProviderType = 'CLOUDFLARE'; + break; + } + return dnsProviderType; + } + + Future> launchInstallation( + final LaunchInstallationData installationData, + ) async { + final volumeResult = await _adapter.api().createVolume(); + + if (!volumeResult.success || volumeResult.data == null) { + return GenericResult( + data: CallbackDialogueBranching( + choices: [ + CallbackDialogueChoice( + title: 'basis.cancel'.tr(), + callback: null, + ), + CallbackDialogueChoice( + title: 'basis.try_again'.tr(), + callback: () async => launchInstallation(installationData), + ), + ], + description: + volumeResult.message ?? 'modals.volume_creation_error'.tr(), + title: 'modals.unexpected_error'.tr(), + ), + success: false, + message: volumeResult.message, + code: volumeResult.code, + ); + } + + final volume = volumeResult.data!; + final serverApiToken = StringGenerators.apiToken(); + final hostname = getHostnameFromDomain(installationData.domainName); + + final serverResult = await _adapter.api().createServer( + dnsApiToken: installationData.dnsApiToken, + rootUser: installationData.rootUser, + domainName: installationData.domainName, + serverType: installationData.serverType.identifier, + dnsProviderType: + dnsProviderToInfectName(installationData.dnsProviderType), + hostName: hostname, + volumeId: volume.id, + base64Password: base64.encode( + utf8.encode(installationData.rootUser.password ?? 'PASS'), + ), + databasePassword: StringGenerators.dbPassword(), + serverApiToken: serverApiToken, + ); + + if (!serverResult.success || serverResult.data == null) { + await _adapter.api().deleteVolume(volume); + await Future.delayed(const Duration(seconds: 5)); + if (serverResult.message != null && + serverResult.message == 'uniqueness_error') { + return GenericResult( + data: CallbackDialogueBranching( + choices: [ + CallbackDialogueChoice( + title: 'basis.cancel'.tr(), + callback: null, + ), + CallbackDialogueChoice( + title: 'basis.yes'.tr(), + callback: () async { + final deleting = await deleteServer(hostname); + if (deleting.success) { + return launchInstallation(installationData); + } + + return deleting; + }, + ), + ], + description: volumeResult.message ?? 'modals.destroy_server'.tr(), + title: 'modals.already_exists'.tr(), + ), + success: false, + message: volumeResult.message, + code: volumeResult.code, + ); + } else { + return GenericResult( + data: CallbackDialogueBranching( + choices: [ + CallbackDialogueChoice( + title: 'basis.cancel'.tr(), + callback: null, + ), + CallbackDialogueChoice( + title: 'basis.try_again'.tr(), + callback: () async => launchInstallation(installationData), + ), + ], + description: + volumeResult.message ?? 'recovering.generic_error'.tr(), + title: 'modals.unexpected_error'.tr(), + ), + success: false, + message: volumeResult.message, + code: volumeResult.code, + ); + } + } + + final serverDetails = ServerHostingDetails( + id: serverResult.data['server']['id'], + ip4: serverResult.data['server']['public_net']['ipv4']['ip'], + createTime: DateTime.now(), + volume: volume, + apiToken: serverApiToken, + provider: ServerProviderType.hetzner, + ); + + final createDnsResult = await _adapter.api().createReverseDns( + serverId: serverDetails.id, + ip4: serverDetails.ip4, + dnsPtr: installationData.domainName, + ); + + if (!createDnsResult.success) { + return GenericResult( + data: CallbackDialogueBranching( + choices: [ + CallbackDialogueChoice( + title: 'basis.cancel'.tr(), + callback: null, + ), + CallbackDialogueChoice( + title: 'basis.try_again'.tr(), + callback: () async { + final deletion = await deleteServer(hostname); + if (deletion.success) { + return launchInstallation(installationData); + } + + return deletion; + }, + ), + ], + description: volumeResult.message ?? 'recovering.generic_error'.tr(), + title: 'modals.unexpected_error'.tr(), + ), + success: false, + message: volumeResult.message, + code: volumeResult.code, + ); + } + } + + Future> deleteServer( + final String hostname, + ) async {} }