diff --git a/assets/translations/en.json b/assets/translations/en.json index 7453c5dc..57bd579b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -624,6 +624,10 @@ "use_staging_acme_description": "Applies when setting up a new server.", "ignore_tls": "Do not verify TLS certificates", "ignore_tls_description": "App will not verify TLS certificates when connecting to the server.", + "allow_ssh_key_at_setup": "Allow setting a root SSH key during setup", + "allow_ssh_key_at_setup_description": "A button to add a key will appear on the confirmation screen.", + "add_root_ssh_key": "Add a root SSH key", + "root_ssh_key_added": "Root SSH key set and will be applied", "routing": "App routing", "reset_onboarding": "Reset onboarding switch", "reset_onboarding_description": "Reset onboarding switch to show onboarding screen again", diff --git a/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart index bd453dee..26f1cc8b 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart @@ -44,7 +44,7 @@ class DigitalOceanApi extends RestApiMap { @override String get rootAddress => 'https://api.digitalocean.com/v2'; - String get infectProviderName => 'digitalocean'; + String get infectProviderName => 'DIGITALOCEAN'; Future> getServers() async { List servers = []; @@ -77,6 +77,7 @@ class DigitalOceanApi extends RestApiMap { required final String domainName, required final String hostName, required final String serverType, + required final String? customSshKey, }) async { final String stagingAcme = TlsOptions.stagingAcme ? 'true' : 'false'; @@ -90,10 +91,12 @@ class DigitalOceanApi extends RestApiMap { 'image': 'ubuntu-20-04-x64', 'user_data': '#cloud-config\n' 'runcmd:\n' - '- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/providers/digital-ocean/nixos-infect | ' - "PROVIDER=$infectProviderName DNS_PROVIDER_TYPE=$dnsProviderType STAGING_ACME='$stagingAcme' 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', + '- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/master/nixos-infect | ' + "API_TOKEN=$serverApiToken CONFIG_URL='https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-template/archive/master.tar.gz' " + "DNS_PROVIDER_TOKEN=$dnsApiToken DNS_PROVIDER_TYPE=$dnsProviderType DOMAIN='$domainName' ENCODED_PASSWORD='$base64Password' " + "HOSTNAME=$hostName LUSER='${rootUser.login}' NIX_VERSION=2.18.1 PROVIDER=$infectProviderName STAGING_ACME='$stagingAcme' " + "${customSshKey != null ? "SSH_AUTHORIZED_KEY='$customSshKey'" : ""} " + 'bash 2>&1 | tee /root/nixos-infect.log', 'region': region!, }; print('Decoded data: $data'); 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 fe317327..89b6eb31 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 @@ -45,7 +45,7 @@ class HetznerApi extends RestApiMap { @override String get rootAddress => 'https://api.hetzner.cloud/v1'; - String get infectProviderName => 'hetzner'; + String get infectProviderName => 'HETZNER'; Future>> getServers() async { List servers = []; @@ -83,6 +83,7 @@ class HetznerApi extends RestApiMap { required final String hostName, required final int volumeId, required final String serverType, + required final String? customSshKey, }) async { final String stagingAcme = TlsOptions.stagingAcme ? 'true' : 'false'; Response? serverCreateResponse; @@ -101,11 +102,12 @@ class HetznerApi extends RestApiMap { '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', + '- curl https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-infect/raw/branch/master/nixos-infect | ' + "API_TOKEN=$serverApiToken CONFIG_URL='https://git.selfprivacy.org/SelfPrivacy/selfprivacy-nixos-template/archive/master.tar.gz' " + "DNS_PROVIDER_TOKEN=$dnsApiToken DNS_PROVIDER_TYPE=$dnsProviderType DOMAIN='$domainName' ENCODED_PASSWORD='$base64Password' " + "HOSTNAME=$hostName LUSER='${rootUser.login}' NIX_VERSION=2.18.1 PROVIDER=$infectProviderName STAGING_ACME='$stagingAcme' " + "${customSshKey != null ? "SSH_AUTHORIZED_KEY='$customSshKey'" : ""} " + 'bash 2>&1 | tee /root/nixos-infect.log', 'labels': {}, 'automount': true, 'location': region!, diff --git a/lib/logic/api_maps/tls_options.dart b/lib/logic/api_maps/tls_options.dart index b216841c..91f93f73 100644 --- a/lib/logic/api_maps/tls_options.dart +++ b/lib/logic/api_maps/tls_options.dart @@ -13,4 +13,6 @@ class TlsOptions { /// /// Doesn't matter if 'statingAcme' is set to 'true' static bool verifyCertificate = false; + + static bool allowCustomSshKeyDuringSetup = false; } diff --git a/lib/logic/cubit/forms/user/ssh_form_cubit.dart b/lib/logic/cubit/forms/user/ssh_form_cubit.dart index 3305d647..d66c8d52 100644 --- a/lib/logic/cubit/forms/user/ssh_form_cubit.dart +++ b/lib/logic/cubit/forms/user/ssh_form_cubit.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:cubit_form/cubit_form.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart'; +import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/logic/models/job.dart'; import 'package:selfprivacy/logic/models/hive/user.dart'; @@ -50,3 +51,39 @@ class SshFormCubit extends FormCubit { final JobsCubit jobsCubit; final User user; } + +class JoblessSshFormCubit extends FormCubit { + JoblessSshFormCubit( + this.serverInstallationCubit, + ) { + final RegExp keyRegExp = RegExp( + r'^(ecdsa-sha2-nistp256 AAAAE2VjZH|ssh-rsa AAAAB3NzaC1yc2|ssh-ed25519 AAAAC3NzaC1lZDI1NTE5)[0-9A-Za-z+/]+[=]{0,3}( .*)?$', + ); + + key = FieldCubit( + initalValue: '', + validations: [ + RequiredStringValidation('validations.required'.tr()), + ValidationModel( + (final String s) { + print(s); + print(keyRegExp.hasMatch(s)); + return !keyRegExp.hasMatch(s); + }, + 'validations.invalid_format_ssh'.tr(), + ), + ], + ); + + super.addFields([key]); + } + + @override + FutureOr onSubmit() { + serverInstallationCubit.setCustomSshKey(key.state.value); + } + + final ServerInstallationCubit serverInstallationCubit; + + late FieldCubit key; +} diff --git a/lib/logic/cubit/server_installation/server_installation_cubit.dart b/lib/logic/cubit/server_installation/server_installation_cubit.dart index f4cd8ae1..0b5b6eed 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -283,6 +283,10 @@ class ServerInstallationCubit extends Cubit { runDelayed(startServerIfDnsIsOkay, const Duration(seconds: 30), null); } + void setCustomSshKey(final String key) async { + emit((state as ServerInstallationNotFinished).copyWith(customSshKey: key)); + } + void createServerAndSetDnsRecords() async { emit((state as ServerInstallationNotFinished).copyWith(isLoading: true)); @@ -295,6 +299,7 @@ class ServerInstallationCubit extends Cubit { errorCallback: clearAppConfig, successCallback: onCreateServerSuccess, storageSize: initialStorage, + customSshKey: (state as ServerInstallationNotFinished).customSshKey, ); final result = @@ -851,6 +856,7 @@ class ServerInstallationCubit extends Cubit { dnsApiToken: state.dnsApiToken, backblazeCredential: state.backblazeCredential, rootUser: state.rootUser, + customSshKey: null, serverDetails: null, isServerStarted: false, isServerResetedFirstTime: false, diff --git a/lib/logic/cubit/server_installation/server_installation_repository.dart b/lib/logic/cubit/server_installation/server_installation_repository.dart index d8f7050a..68534eb8 100644 --- a/lib/logic/cubit/server_installation/server_installation_repository.dart +++ b/lib/logic/cubit/server_installation/server_installation_repository.dart @@ -150,6 +150,7 @@ class ServerInstallationRepository { box.get(BNames.isServerResetedSecondTime, defaultValue: false), isLoading: box.get(BNames.isLoading, defaultValue: false), dnsMatches: null, + customSshKey: null, ); } diff --git a/lib/logic/cubit/server_installation/server_installation_state.dart b/lib/logic/cubit/server_installation/server_installation_state.dart index e06f87c3..3f3ea267 100644 --- a/lib/logic/cubit/server_installation/server_installation_state.dart +++ b/lib/logic/cubit/server_installation/server_installation_state.dart @@ -100,6 +100,7 @@ class TimerState extends ServerInstallationNotFinished { isServerResetedSecondTime: dataState.isServerResetedSecondTime, dnsMatches: dataState.dnsMatches, installationDialoguePopUp: dataState.installationDialoguePopUp, + customSshKey: dataState.customSshKey, ); final ServerInstallationNotFinished dataState; @@ -135,6 +136,7 @@ class ServerInstallationNotFinished extends ServerInstallationState { required super.isServerResetedSecondTime, required this.isLoading, required this.dnsMatches, + required this.customSshKey, super.providerApiToken, super.serverTypeIdentificator, super.dnsApiToken, @@ -146,6 +148,7 @@ class ServerInstallationNotFinished extends ServerInstallationState { }); final bool isLoading; final Map? dnsMatches; + final String? customSshKey; @override List get props => [ @@ -160,6 +163,7 @@ class ServerInstallationNotFinished extends ServerInstallationState { isServerResetedFirstTime, isLoading, dnsMatches, + customSshKey, installationDialoguePopUp, ]; @@ -177,6 +181,7 @@ class ServerInstallationNotFinished extends ServerInstallationState { final bool? isLoading, final Map? dnsMatches, final CallbackDialogueBranching? installationDialoguePopUp, + final String? customSshKey, }) => ServerInstallationNotFinished( providerApiToken: providerApiToken ?? this.providerApiToken, @@ -196,6 +201,7 @@ class ServerInstallationNotFinished extends ServerInstallationState { dnsMatches: dnsMatches ?? this.dnsMatches, installationDialoguePopUp: installationDialoguePopUp ?? this.installationDialoguePopUp, + customSshKey: customSshKey ?? this.customSshKey, ); ServerInstallationFinished finish() => ServerInstallationFinished( @@ -229,6 +235,7 @@ class ServerInstallationEmpty extends ServerInstallationNotFinished { isLoading: false, dnsMatches: null, installationDialoguePopUp: null, + customSshKey: null, ); } diff --git a/lib/logic/models/launch_installation_data.dart b/lib/logic/models/launch_installation_data.dart index 00d89335..eea963a0 100644 --- a/lib/logic/models/launch_installation_data.dart +++ b/lib/logic/models/launch_installation_data.dart @@ -13,6 +13,7 @@ class LaunchInstallationData { required this.errorCallback, required this.successCallback, required this.storageSize, + required this.customSshKey, }); final User rootUser; @@ -23,4 +24,5 @@ class LaunchInstallationData { final Function() errorCallback; final Function(ServerHostingDetails details) successCallback; final DiskSize storageSize; + final String? customSshKey; } diff --git a/lib/logic/providers/server_providers/digital_ocean.dart b/lib/logic/providers/server_providers/digital_ocean.dart index 6e699723..741e35ac 100644 --- a/lib/logic/providers/server_providers/digital_ocean.dart +++ b/lib/logic/providers/server_providers/digital_ocean.dart @@ -228,6 +228,7 @@ class DigitalOceanServerProvider extends ServerProvider { ), databasePassword: StringGenerators.dbPassword(), serverApiToken: serverApiToken, + customSshKey: installationData.customSshKey, ); if (!serverResult.success || serverResult.data == null) { diff --git a/lib/logic/providers/server_providers/hetzner.dart b/lib/logic/providers/server_providers/hetzner.dart index 560a0342..58e66701 100644 --- a/lib/logic/providers/server_providers/hetzner.dart +++ b/lib/logic/providers/server_providers/hetzner.dart @@ -211,6 +211,7 @@ class HetznerServerProvider extends ServerProvider { ), databasePassword: StringGenerators.dbPassword(), serverApiToken: serverApiToken, + customSshKey: installationData.customSshKey, ); if (!serverResult.success || serverResult.data == null) { diff --git a/lib/ui/components/buttons/brand_button.dart b/lib/ui/components/buttons/brand_button.dart index a07a1de0..c381af43 100644 --- a/lib/ui/components/buttons/brand_button.dart +++ b/lib/ui/components/buttons/brand_button.dart @@ -39,8 +39,14 @@ class BrandButton { onPressed: onPressed, style: ElevatedButton.styleFrom( tapTargetSize: MaterialTapTargetSize.padded, + padding: const EdgeInsets.symmetric(horizontal: 16.0), ), - child: child ?? Text(text ?? ''), + child: child ?? + Text( + text ?? '', + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), ), ); } diff --git a/lib/ui/pages/more/app_settings/developer_settings.dart b/lib/ui/pages/more/app_settings/developer_settings.dart index 3c3addb4..aa5b2368 100644 --- a/lib/ui/pages/more/app_settings/developer_settings.dart +++ b/lib/ui/pages/more/app_settings/developer_settings.dart @@ -50,6 +50,16 @@ class _DeveloperSettingsPageState extends State { () => TlsOptions.verifyCertificate = value, ), ), + SwitchListTile( + title: Text('developer_settings.allow_ssh_key_at_setup'.tr()), + subtitle: Text( + 'developer_settings.allow_ssh_key_at_setup_description'.tr(), + ), + value: TlsOptions.allowCustomSshKeyDuringSetup, + onChanged: (final bool value) => setState( + () => TlsOptions.allowCustomSshKeyDuringSetup = value, + ), + ), Padding( padding: const EdgeInsets.all(16), child: Text( diff --git a/lib/ui/pages/recovery_key/recovery_key.dart b/lib/ui/pages/recovery_key/recovery_key.dart index 86d678bb..ff449377 100644 --- a/lib/ui/pages/recovery_key/recovery_key.dart +++ b/lib/ui/pages/recovery_key/recovery_key.dart @@ -89,10 +89,14 @@ class _RecoveryKeyContentState extends State { children: [ if (keyStatus.exists) RecoveryKeyStatusCard(isValid: keyStatus.isValid), const SizedBox(height: 16), - if (keyStatus.exists && !_isConfigurationVisible) - RecoveryKeyInformation(state: keyStatus), - if (_isConfigurationVisible || !keyStatus.exists) - const RecoveryKeyConfiguration(), + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + firstChild: RecoveryKeyInformation(state: keyStatus), + secondChild: const RecoveryKeyConfiguration(), + crossFadeState: _isConfigurationVisible || !keyStatus.exists + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + ), const SizedBox(height: 16), if (!_isConfigurationVisible && keyStatus.isValid && keyStatus.exists) BrandButton.text( diff --git a/lib/ui/pages/setup/initializing/initializing.dart b/lib/ui/pages/setup/initializing/initializing.dart index 02c29269..694dd16e 100644 --- a/lib/ui/pages/setup/initializing/initializing.dart +++ b/lib/ui/pages/setup/initializing/initializing.dart @@ -2,7 +2,9 @@ import 'package:auto_route/auto_route.dart'; import 'package:cubit_form/cubit_form.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/api_maps/tls_options.dart'; import 'package:selfprivacy/logic/cubit/forms/setup/initializing/server_provider_form_cubit.dart'; +import 'package:selfprivacy/logic/cubit/forms/user/ssh_form_cubit.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/logic/cubit/forms/factories/field_cubit_factory.dart'; import 'package:selfprivacy/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart'; @@ -420,6 +422,9 @@ class InitializingPage extends StatelessWidget { Widget _stepServer(final ServerInstallationCubit appConfigCubit) { final bool isLoading = (appConfigCubit.state as ServerInstallationNotFinished).isLoading; + final bool hasSshKey = + (appConfigCubit.state as ServerInstallationNotFinished).customSshKey != + null; return Builder( builder: (final context) => ResponsiveLayoutWithInfobox( topChild: Column( @@ -436,12 +441,40 @@ class InitializingPage extends StatelessWidget { ), ], ), - primaryColumn: BrandButton.filled( - onPressed: - isLoading ? null : appConfigCubit.createServerAndSetDnsRecords, - text: isLoading - ? 'basis.loading'.tr() - : 'initializing.create_server'.tr(), + primaryColumn: Column( + children: [ + BrandButton.filled( + onPressed: isLoading + ? null + : appConfigCubit.createServerAndSetDnsRecords, + text: isLoading + ? 'basis.loading'.tr() + : 'initializing.create_server'.tr(), + ), + const SizedBox(height: 16), + if (TlsOptions.allowCustomSshKeyDuringSetup) + Column( + children: [ + Text('developer_settings.title'.tr()), + BrandOutlinedButton( + title: hasSshKey + ? 'developer_settings.root_ssh_key_added'.tr() + : 'developer_settings.add_root_ssh_key'.tr(), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (final BuildContext context) => Padding( + padding: MediaQuery.of(context).viewInsets, + child: AddSshKey(appConfigCubit), + ), + ); + }, + ), + ], + ), + ], ), ), ); @@ -540,3 +573,62 @@ class InitializingPage extends StatelessWidget { ); } } + +class AddSshKey extends StatelessWidget { + const AddSshKey(this.serverInstallationCubit, {super.key}); + + final ServerInstallationCubit serverInstallationCubit; + @override + Widget build(final BuildContext context) => BlocProvider( + create: (final context) => JoblessSshFormCubit(serverInstallationCubit), + child: Builder( + builder: (final context) { + final formCubitState = context.watch().state; + + return BlocListener( + listener: (final context, final state) { + if (state.isSubmitted) { + Navigator.pop(context); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 14), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicHeight( + child: CubitFormTextField( + autofocus: true, + formFieldCubit: + context.read().key, + decoration: InputDecoration( + labelText: 'ssh.input_label'.tr(), + ), + ), + ), + const SizedBox(height: 30), + BrandButton.rised( + onPressed: formCubitState.isSubmitting + ? null + : () => context + .read() + .trySubmit(), + text: 'ssh.create'.tr(), + ), + const SizedBox(height: 30), + ], + ), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/ui/pages/users/new_user.dart b/lib/ui/pages/users/new_user.dart index 9212307a..2315bdb1 100644 --- a/lib/ui/pages/users/new_user.dart +++ b/lib/ui/pages/users/new_user.dart @@ -71,12 +71,38 @@ class NewUserPage extends StatelessWidget { labelText: 'basis.password'.tr(), suffixIcon: Padding( padding: const EdgeInsets.only(right: 8), - child: IconButton( - icon: Icon( - BrandIcons.refresh, - color: Theme.of(context).colorScheme.secondary, - ), - onPressed: context.read().genNewPassword, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + Icons.copy, + size: 24.0, + color: Theme.of(context).colorScheme.secondary, + ), + onPressed: () { + final String currentPassword = context + .read() + .password + .state + .value; + PlatformAdapter.setClipboard(currentPassword); + getIt().showSnackBar( + 'basis.copied_to_clipboard'.tr(), + behavior: SnackBarBehavior.floating, + ); + }, + ), + IconButton( + icon: Icon( + Icons.refresh, + size: 24.0, + color: Theme.of(context).colorScheme.secondary, + ), + onPressed: + context.read().genNewPassword, + ), + ], ), ), ),