Compare commits

...

27 Commits

Author SHA1 Message Date
Jiří Podhorecký
020ef6ae0c
Translated using Weblate (Czech)
Currently translated at 100.0% (1885 of 1885 strings)
2026-02-09 20:09:44 +01:00
kosagi
cc8fa47efe
Translated using Weblate (Catalan)
Currently translated at 55.2% (1042 of 1885 strings)
2026-02-07 00:02:02 +01:00
Daniel Wiik
59c3b49d8b
Translated using Weblate (Swedish)
Currently translated at 79.9% (1507 of 1885 strings)
2026-02-07 00:01:55 +01:00
Pierfrancesco Passerini
a4cfb824e8
Translated using Weblate (Italian)
Currently translated at 100.0% (1885 of 1885 strings)
2026-02-07 00:01:50 +01:00
Frederico Gomes
59329169e4
wireguard: filter .local addresses from showClient view
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-06 12:50:05 -08:00
Sunil Mohan Adapa
df7793916c
backups: Fix type checking errors
- Use the passed in argument for copy_ssh_client_public_key instead of
overwriting it.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-06 11:39:04 -08:00
Frederico Gomes
0c8cba3a13
container: Align terminology with !2731 in printed banner
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-06 08:16:53 -08:00
Sunil Mohan Adapa
5da5ef5f96
backups: Create a better comment in the generated SSH key file
Tests:

- When /var/lib/plinth/.ssh is deleted and add remote repository form is
visited, the directory along with SSH key files are created. The .pub file has
the expected comment freedombox@configured_domain.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:49 -08:00
Sunil Mohan Adapa
4ae66c034c
backups: Fix showing proper error for incorrect passphrase
Tests:

- Provide incorrect passphrase when adding a remote repository. Should show a
message that passphrase is incorrect and redirect back to add remote repository
form.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:46 -08:00
Sunil Mohan Adapa
e21ab91b21
backups: Create .ssh folder before creating SSH key
- As a safe guard although it should exist because of a prior verification of
SSH key.

- Minor refactor to make the method flatter.

Tests:

- Remove /var/lib/plinth/.ssh and visit add remote repository form. The public
key is displayed in the form. The files in /var/lib/plinth/.ssh are created with
expected permissions.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:42 -08:00
Sunil Mohan Adapa
dd0a0f56a6
backups: Simplify handling of migration to SSH keys
- Integrate SSH error handling into borg error handling.

- Move logic to migrate SSH keys into lower levels (Repository class) so that it
can performed at more instances such as when initializing repository. It also
provides better abstraction keeping the view logic simpler.

- Drop ability to mount repository using password. This is important next step
for mounting using systemd unit files.

- Use exceptions to eliminate return value checking.

- Create a special exception for exceptions raised during SSH operations. Raise
this at lower levels and handle these using the common error handler.

Tests:

- Adding a remote repository with key and password authentication works with and
without encryption. Adding works with SSH host key pre-verified works too.

- Trying to add a remote repository with incorrect passpharse fails with the
simplified error message. Redirect happens to add remote repository page. Error
message with SSH host key pre-verified works too. Repository is removed.

- Trying to provide wrong SSH password fails with a simplified error message.
Redirect happens to add remote repository page. Repository is removed.

- Mounting a repository after unmounting it works.

- Mounting a repository with SSH password in its configuration works. Migration
is performed and SSH password is replaced with SSH key file path.

- A schedule for a repository with SSH password runs successfully. An archive is
created. Migration is performed and SSH password is replaced with SSH key file
path.

- SSH identity files are created with plinth:plinth ownership. Private key file
is created with 0o600 permissions and public key file is created with 0o644
permissions.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:39 -08:00
Sunil Mohan Adapa
a7ef60015c
backups: Minor refactoring
Tests:

- Adding a remote repository with key and password authentication works with and
without encryption.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:36 -08:00
Sunil Mohan Adapa
2208a7b210
backups: tests: Simplify functional test using more classes
- This makes the functional test focus more on the repository that is added
rather than all remote repositories.

Tests:

- Functional tests for backups app works.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:33 -08:00
Sunil Mohan Adapa
ff7c3a53a5
backups: Tweak appearance of add remote location form
- Use bootstrapform templates where possible to reduce code.

- Fix incorrect tag for radio button for password authentication.

- Drop borders and instead:

  - Style each group as a section. This includes encryption section and
  repository path section.

  - Utilize the simplicity as passwords fields are not displayed.

- Retain <label> tag so that clicking on it works and it also easier for
accessibility tools.

Tests:

- The option for password authentication works are expected. The fields for
encryption passphrase, ssh password work as before.

- Clicking on form labels focuses the form element.

- Adding a repository with key and password authentication works with and
without encryption.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:30 -08:00
Sunil Mohan Adapa
7d3d930137
backups: Show/hide form elements instead of disabling for simplicity
- Unlike the case of network forms, for example, there is nothing the user could
infer from a disabled form element. If they see a disabled DNS field, they would
understand that it is an editable value but has been disabled due to other
option values. It is important to allow users to discover this. However, in case
of password fields, they are not needed to be shown to the user unless the
appropriate option is selected.

Tests:

- In the add remote repository form, selecting the authentication type radio
options shows and hides the password field. Selecting the value for encryption
type shows and hides the encryption password field.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:26 -08:00
Sunil Mohan Adapa
ad40072267
backups: Fix issue with Javascript in add remote location form
Tests:

- In remote repository add form, selecting radio options for authentication
types does not throw an error. The password field is shown/hidden as expected.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:23 -08:00
Sunil Mohan Adapa
305b1f01f5
backups: Avoid some repeated text in form help text
Tests:

- The remote repository add form shows form elements are updated as expected.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:20 -08:00
Sunil Mohan Adapa
58ecf9d3e4
actions, privileged_daemon: Drop some unused global statements
Tests:

- Make a privileged method throw and exception after spewing output to stdout
and stderr. The exception caught on the service daemon contains the expected
stdout and stderr messages.

- Sending SIGTERM to privileged daemon shuts down the daemon.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
2026-02-05 22:33:17 -08:00
James Valleroy
3cb5d1a936
backups: Migrate to SSH key auth when mounting
Tests:

- On main branch, add a remote repository with SSH password. Unmount
  the remote location.

- Switch to branch with this change. Mount the remote location. Logs
  show that it is migrated from password to key authentication. Plinth
  database no longer contains password for this remote.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:33:14 -08:00
James Valleroy
8b9413c719
backups: Arrange form for adding remote location
- Group together related fields with borders.

- Display errors on form and fields.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:33:11 -08:00
James Valleroy
451e582c07
backups: Test adding/removing remote location
Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:33:07 -08:00
James Valleroy
043bd44dec
backups: Use selected SSH credential for remote
- Use javascript to disable or enable password fields.

- If SSH key auth is selected, then try the connection.

- If SSH password auth is selected, then copy the key.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:33:04 -08:00
James Valleroy
3558a26b2f
backups: Use SSH key instead of password
- After copying the SSH client public key to the remote host, replace
  the SSH password credential with keyfile.

- Also use SSH key when checking that remote directory exists.

Tests:

- Add remote backup location "tester@localhost:~backups". Test various
  operations like create backup, download backup, unmount and
  mount. Confirm that SSH password is no longer present in plinth
  sqlite database.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:33:01 -08:00
James Valleroy
f689e1b3cf
backups: Copy SSH client public key to remote
Tests:

- In development VM, add a remote backup location of "tester@localhost:~/backups".
  Verify the SSH host key. plinth@freedombox key is listed in
  /home/tester/.ssh/authorized_keys.

- Remove the remote backup location, and delete /home/tester/.ssh/authorized_keys.
  Add the same remote backup location again. plinth@freedombox key is again
  listed in /home/tester/.ssh/authorized_keys.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:32:58 -08:00
James Valleroy
7fb41313cd
backups: Display SSH public key when adding remote
Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:32:55 -08:00
James Valleroy
156d0b761f
backups: Generate SSH client key if needed
Tests:

- Click on Add Remote Backup Location. Logs show that SSH client key is
  generated. The private key is readable only by plinth user.

- Go back, and click on Add Remote Backup Location again. Logs show that SSH
  client key already exists.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
2026-02-05 22:32:52 -08:00
Dietmar
33d05ef5be
Translated using Weblate (German)
Currently translated at 99.2% (1870 of 1885 strings)
2026-02-05 11:01:51 +00:00
21 changed files with 957 additions and 517 deletions

View File

@ -1064,7 +1064,7 @@ Terminal login : sudo machinectl login fbx-{distribution}
Open a root shell : sudo machinectl shell fbx-{distribution}
Shutdown : {script} stop {options}
Destroy : {script} destroy {options}
Reset : {script} destroy {options}; {script} up {options}'''
Reset : {script} destroy {options}; {script} start {options}'''
logger.info(message)

View File

@ -369,7 +369,6 @@ class JSONEncoder(json.JSONEncoder):
def _setup_thread_storage():
"""Setup collection of stdout/stderr from any process in this thread."""
global thread_storage
thread_storage.stdout = b''
thread_storage.stderr = b''
@ -380,14 +379,12 @@ def _clear_thread_storage():
Python documentation is silent on whether thread local storage will be
cleaned up after a thread terminates.
"""
global thread_storage
thread_storage.stdout = None
thread_storage.stderr = None
def get_return_value_from_exception(exception):
"""Return the value to return from server when an exception is raised."""
global thread_storage
return_value = {
'result': 'exception',
'exception': {

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 01:14+0000\n"
"PO-Revision-Date: 2025-12-17 07:00+0000\n"
"PO-Revision-Date: 2026-02-09 19:09+0000\n"
"Last-Translator: Jiří Podhorecký <j.podhorecky@volny.cz>\n"
"Language-Team: Czech <https://hosted.weblate.org/projects/freedombox/"
"freedombox/cs/>\n"
@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\n"
"X-Generator: Weblate 5.15.1-dev\n"
"X-Generator: Weblate 5.16-dev\n"
#: plinth/config.py:103
#, python-brace-format
@ -9934,15 +9934,14 @@ msgid "Not configured yet."
msgstr "Zatím není nakonfigurováno."
#: plinth/modules/wireguard/templates/wireguard.html:59
#, fuzzy, python-format
#| msgid "Public key for this %(box_name)s:"
#, python-format
msgid "Endpoints for this %(box_name)s:"
msgstr "Veřejný klíč pro tento %(box_name)s:"
msgstr "Endpointy pro tento %(box_name)s:"
#: plinth/modules/wireguard/templates/wireguard.html:75
#: plinth/modules/wireguard/templates/wireguard.html:77
msgid "Start WireGuard Server"
msgstr ""
msgstr "Spustit WireGuard Server"
#: plinth/modules/wireguard/templates/wireguard.html:81
msgid "Add a new peer"
@ -9981,10 +9980,8 @@ msgid "Add Connection to Server"
msgstr "Přidat připojení k serveru"
#: plinth/modules/wireguard/templates/wireguard_add_client.html:21
#, fuzzy
#| msgid "IP address to use for client:"
msgid "IP address that will be assigned to this client"
msgstr "IP adresa, která se použije pro klienta:"
msgstr "IP adresa, která bude přiřazena k tomuto klientovi"
#: plinth/modules/wireguard/templates/wireguard_add_client.html:31
msgid "Add Client"
@ -10132,14 +10129,12 @@ msgid "Server deleted."
msgstr "Server smazán."
#: plinth/modules/wireguard/views.py:286
#, fuzzy
#| msgid "Password changed successfully."
msgid "WireGuard server started successfully."
msgstr "Heslo úspěšně změněno."
msgstr "Server WireGuard se úspěšně spustil."
#: plinth/modules/wireguard/views.py:290
msgid "Failed to start WireGuard server: {}"
msgstr ""
msgstr "Spuštění serveru WireGuard selhalo: {}"
#: plinth/modules/wordpress/__init__.py:20
msgid ""

View File

@ -10,8 +10,8 @@ msgstr ""
"Project-Id-Version: FreedomBox UI\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 01:14+0000\n"
"PO-Revision-Date: 2026-01-25 14:02+0000\n"
"Last-Translator: Ettore Atalan <atalanttore@googlemail.com>\n"
"PO-Revision-Date: 2026-02-05 11:01+0000\n"
"Last-Translator: Dietmar <sagen@permondes.de>\n"
"Language-Team: German <https://hosted.weblate.org/projects/freedombox/"
"freedombox/de/>\n"
"Language: de\n"
@ -10156,17 +10156,14 @@ msgid "Not configured yet."
msgstr "Noch nicht konfiguriert."
#: plinth/modules/wireguard/templates/wireguard.html:59
#, fuzzy, python-format
#| msgid "Public key for this %(box_name)s:"
#, python-format
msgid "Endpoints for this %(box_name)s:"
msgstr "Öffentlicher Schlüssel für diese %(box_name)s:"
msgstr "Endpunkte für diese %(box_name)s:"
#: plinth/modules/wireguard/templates/wireguard.html:75
#: plinth/modules/wireguard/templates/wireguard.html:77
#, fuzzy
#| msgid "Standard Services"
msgid "Start WireGuard Server"
msgstr "Standarddienste"
msgstr "WireGuard-Server starten"
#: plinth/modules/wireguard/templates/wireguard.html:81
msgid "Add a new peer"
@ -10205,10 +10202,8 @@ msgid "Add Connection to Server"
msgstr "Verbindung zum Server hinzufügen"
#: plinth/modules/wireguard/templates/wireguard_add_client.html:21
#, fuzzy
#| msgid "IP address to use for client:"
msgid "IP address that will be assigned to this client"
msgstr "IP-Adresse, die für den Client verwendet werden soll:"
msgstr "IP-Adresse, die diesem Client zugewiesen wird"
#: plinth/modules/wireguard/templates/wireguard_add_client.html:31
msgid "Add Client"
@ -10357,14 +10352,12 @@ msgid "Server deleted."
msgstr "Server gelöscht."
#: plinth/modules/wireguard/views.py:286
#, fuzzy
#| msgid "Password changed successfully."
msgid "WireGuard server started successfully."
msgstr "Passwort erfolgreich geändert."
msgstr "Der WireGuard-Server wurde erfolgreich gestartet."
#: plinth/modules/wireguard/views.py:290
msgid "Failed to start WireGuard server: {}"
msgstr ""
msgstr "Starten des WireGuard-Servers fehlgeschlagen: {}"
#: plinth/modules/wordpress/__init__.py:20
msgid ""

View File

@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 01:14+0000\n"
"PO-Revision-Date: 2026-02-03 01:07+0000\n"
"PO-Revision-Date: 2026-02-06 23:01+0000\n"
"Last-Translator: Pierfrancesco Passerini <p.passerini@gmail.com>\n"
"Language-Team: Italian <https://hosted.weblate.org/projects/freedombox/"
"freedombox/it/>\n"
@ -10026,15 +10026,14 @@ msgid "Not configured yet."
msgstr "Non ancora configurato."
#: plinth/modules/wireguard/templates/wireguard.html:59
#, fuzzy, python-format
#| msgid "Public key for this %(box_name)s:"
#, python-format
msgid "Endpoints for this %(box_name)s:"
msgstr "Chiave pubblica di %(box_name)s:"
msgstr "Punti di accesso per %(box_name)s:"
#: plinth/modules/wireguard/templates/wireguard.html:75
#: plinth/modules/wireguard/templates/wireguard.html:77
msgid "Start WireGuard Server"
msgstr ""
msgstr "Avvia WireGuard server"
#: plinth/modules/wireguard/templates/wireguard.html:81
msgid "Add a new peer"
@ -10073,10 +10072,8 @@ msgid "Add Connection to Server"
msgstr "Aggiungi Connessione a Server"
#: plinth/modules/wireguard/templates/wireguard_add_client.html:21
#, fuzzy
#| msgid "IP address to use for client:"
msgid "IP address that will be assigned to this client"
msgstr "Indirizzo IP da utilizzare sul client:"
msgstr "Indirizzo IP che sarà assegnato al client"
#: plinth/modules/wireguard/templates/wireguard_add_client.html:31
msgid "Add Client"
@ -10224,14 +10221,12 @@ msgid "Server deleted."
msgstr "Server cancellato."
#: plinth/modules/wireguard/views.py:286
#, fuzzy
#| msgid "Password changed successfully."
msgid "WireGuard server started successfully."
msgstr "La password è stata aggiornata."
msgstr "Il server WireGuard è stato correttamente avviato."
#: plinth/modules/wireguard/views.py:290
msgid "Failed to start WireGuard server: {}"
msgstr ""
msgstr "Avvio del server WireGuard non riuscito: {}"
#: plinth/modules/wordpress/__init__.py:20
msgid ""

View File

@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 01:14+0000\n"
"PO-Revision-Date: 2024-07-30 01:31+0000\n"
"Last-Translator: bittin1ddc447d824349b2 <bittin@reimu.nl>\n"
"PO-Revision-Date: 2026-02-06 23:01+0000\n"
"Last-Translator: Daniel Wiik <d.wiik@outlook.com>\n"
"Language-Team: Swedish <https://hosted.weblate.org/projects/freedombox/"
"freedombox/sv/>\n"
"Language: sv\n"
@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.7-dev\n"
"X-Generator: Weblate 5.16-dev\n"
#: plinth/config.py:103
#, python-brace-format
@ -25,10 +25,9 @@ msgid "Static configuration {etc_path} is setup properly"
msgstr "Statisk konfiguration {etc_path} är inställd korrekt"
#: plinth/container.py:140
#, fuzzy, python-brace-format
#| msgid "Service {service_name} is running"
#, python-brace-format
msgid "Container {container_name} is running"
msgstr "Tjänsten {service_name} körs"
msgstr "Tjänsten {container_name} körs"
#: plinth/context_processors.py:21 plinth/views.py:175
msgid "FreedomBox"
@ -72,10 +71,8 @@ msgid "Repository to backup to"
msgstr "Repository att säkerhetskopiera till"
#: plinth/forms.py:62
#, fuzzy
#| msgid "None"
msgid "(None)"
msgstr "Ingen"
msgstr "(Ingen)"
#: plinth/forms.py:68
msgid "Select a domain name to be used with this application"
@ -143,22 +140,17 @@ msgid "System is possibly under heavy load. Please retry later."
msgstr "Systemet är möjligen under tung belastning. Försök igen senare."
#: plinth/middleware.py:147
#, fuzzy, python-brace-format
#| msgid "Page not found - %(box_name)s"
#, python-brace-format
msgid "Page not found: {url}"
msgstr "Sidan hittades inte - %(box_name)s"
msgstr "Sidan hittades inte: {url}"
#: plinth/middleware.py:150
#, fuzzy
#| msgid "Error running apt-get"
msgid "Error running operation."
msgstr "Fel vid körning av apt-get"
msgstr "Fel vid körning av åtgärden."
#: plinth/middleware.py:152
#, fuzzy
#| msgid "Error running apt-get"
msgid "Error loading page."
msgstr "Fel vid körning av apt-get"
msgstr "Fel vid laddning av sida."
#: plinth/modules/apache/__init__.py:33
msgid "Apache HTTP Server"
@ -210,15 +202,15 @@ msgstr "Lokalt nätverksdomän"
#: plinth/modules/avahi/manifest.py:14
msgid "Auto-discovery"
msgstr ""
msgstr "Autoupptäck"
#: plinth/modules/avahi/manifest.py:14 plinth/modules/backups/manifest.py:17
msgid "Local"
msgstr ""
msgstr "Lokal"
#: plinth/modules/avahi/manifest.py:14
msgid "mDNS"
msgstr ""
msgstr "mDNS"
#: plinth/modules/backups/__init__.py:24
msgid "Backups allows creating and managing backup archives."
@ -328,6 +320,8 @@ msgid ""
"In 24 hour format. Services may become temporarily unavailable while running "
"backup operation at this time of the day."
msgstr ""
"24-timmars format. Tjänster kan bli tillfälligt otillgängliga när du kör "
"säkerhetskopiering vid denna tidpunkt på dagen."
#: plinth/modules/backups/forms.py:85 plinth/modules/backups/forms.py:107
msgid "Included apps"
@ -373,6 +367,9 @@ msgid ""
"file previously downloaded from the result of a successful backup on a "
"{box_name}. It must have a .tar.gz extension."
msgstr ""
"Välj backup-filen för att ladda upp från den lokala datorn. Detta måste vara "
"en fil som tidigare laddats ner från resultatet av en framgångsrik "
"säkerhetskopia på en {box_name}. Det måste ha en .tar.gz förlängning."
#: plinth/modules/backups/forms.py:154
msgid "Repository path format incorrect."
@ -474,16 +471,12 @@ msgid "Restore"
msgstr "Återställa"
#: plinth/modules/backups/manifest.py:15
#, fuzzy
#| msgid "Encryption"
msgid "Encrypted"
msgstr "Kryptering"
msgstr "Krypterad"
#: plinth/modules/backups/manifest.py:16
#, fuzzy
#| msgid "Schedule"
msgid "Schedules"
msgstr "Schema"
msgstr "Scheman"
#: plinth/modules/backups/manifest.py:18
#, fuzzy
@ -492,10 +485,8 @@ msgid "Remote"
msgstr "Ta bort"
#: plinth/modules/backups/manifest.py:19
#, fuzzy
#| msgid "App updated"
msgid "App data"
msgstr "App uppdaterad"
msgstr "App data"
#: plinth/modules/backups/manifest.py:20
#: plinth/modules/homeassistant/templates/homeassistant.html:11
@ -539,22 +530,20 @@ msgid "Repository path is neither empty nor is an existing backups repository."
msgstr "Respositorysökvägen är varken tom eller en befintlig säkerhetskopia."
#: plinth/modules/backups/privileged.py:77
#, fuzzy
#| msgid "A share with this name already exists."
msgid "An archive with given name already exists in the repository."
msgstr "Det finns redan en share med det här namnet."
msgstr "Ett arkiv med givna namn finns redan i kodförrådet."
#: plinth/modules/backups/privileged.py:84
msgid "Archive with given name was not found in the repository."
msgstr ""
msgstr "Arkiv med givna namn hittades inte i kodförrådet."
#: plinth/modules/backups/privileged.py:90
msgid "Backup system is busy with another operation."
msgstr ""
msgstr "Backupsystemet är upptaget med en annan process."
#: plinth/modules/backups/privileged.py:95
msgid "Not enough space left on the disk or remote location."
msgstr ""
msgstr "Inte tillräckligt med utrymme kvar på disken eller fjärrplatsen."
#: plinth/modules/backups/repository.py:93
msgid "Existing repository is not encrypted."
@ -711,27 +700,17 @@ msgid "Restore data from"
msgstr "Återställa data från"
#: plinth/modules/backups/templates/backups_upload.html:17
#, fuzzy, python-format
#| msgid ""
#| "\n"
#| " Upload a backup file downloaded from another %(box_name)s to "
#| "restore its\n"
#| " contents. You can choose the apps you wish to restore after "
#| "uploading a\n"
#| " backup file.\n"
#| " "
#, python-format
msgid ""
"Upload a backup file downloaded from another %(box_name)s to restore its "
"contents. You can choose the apps you wish to restore after uploading a "
"backup file."
msgstr ""
"\n"
" Ladda upp en backup-fil som hämtats från en annan %(box_name)s för att "
"Ladda upp en backup-fil som hämtats från en annan %(box_name)s för att "
"återställa\n"
" Innehållet. Du kan välja vilka appar du vill återställa efter att du "
"laddat upp en\n"
" backup-fil.\n"
" "
" backup-fil."
#: plinth/modules/backups/templates/backups_upload.html:31
#, python-format
@ -807,10 +786,8 @@ msgid "Upload and restore a backup"
msgstr "Ladda upp och återställ en säkerhetskopia"
#: plinth/modules/backups/views.py:216
#, fuzzy
#| msgid "Logged out successfully."
msgid "Upload successful."
msgstr "Du har loggat ut framgångsrikt."
msgstr "Uppladdning genomförd."
#: plinth/modules/backups/views.py:254
msgid "No backup file found."
@ -835,10 +812,8 @@ msgid "Create backup repository"
msgstr "Skapa backup repository"
#: plinth/modules/backups/views.py:350
#, fuzzy
#| msgid "Added new remote SSH repository."
msgid "Added new repository."
msgstr "Lade till ett nytt remote SSH-repository."
msgstr "Lade till nytt kodförråd."
#: plinth/modules/backups/views.py:364
msgid "Create remote backup repository"
@ -997,14 +972,12 @@ msgstr ""
#: plinth/modules/samba/manifest.py:81 plinth/modules/sharing/manifest.py:19
#: plinth/modules/syncthing/manifest.py:58
#: plinth/modules/transmission/manifest.py:39
#, fuzzy
#| msgid "File & Snippet Sharing"
msgid "File sharing"
msgstr "Fil &amp; Snippet Sharing"
msgstr "Fildelning"
#: plinth/modules/bepasty/manifest.py:23
msgid "Pastebin"
msgstr ""
msgstr "Pastebin"
#: plinth/modules/bepasty/templates/bepasty.html:12
msgid "Manage Passwords"
@ -1134,10 +1107,8 @@ msgstr ""
"förfrågningar kommer att vidarebefordras"
#: plinth/modules/bind/manifest.py:16
#, fuzzy
#| msgid "Enable DNSSEC"
msgid "DNS"
msgstr "Aktivera DNSSEC"
msgstr "DNS"
#: plinth/modules/bind/manifest.py:17 plinth/modules/mumble/manifest.py:67
#: plinth/modules/radicale/manifest.py:91
@ -1147,7 +1118,7 @@ msgstr "Server"
#: plinth/modules/bind/manifest.py:18
msgid "Resolver"
msgstr ""
msgstr "Resolver"
#: plinth/modules/bind/templates/bind.html:11
msgid "Serving Domains"
@ -1256,19 +1227,15 @@ msgstr "Ett bibliotek med detta namn finns redan."
#: plinth/modules/calibre/manifest.py:21
msgid "Ebook"
msgstr ""
msgstr "Ebok"
#: plinth/modules/calibre/manifest.py:21
#, fuzzy
#| msgid "E-book Library"
msgid "Library"
msgstr "E-bok Bibliotek"
msgstr "Bibliotek"
#: plinth/modules/calibre/manifest.py:21
#, fuzzy
#| msgid "E-book Library"
msgid "Ebook reader"
msgstr "E-bok Bibliotek"
msgstr "E-boksbibliotek"
#: plinth/modules/calibre/templates/calibre-delete-library.html:11
#, python-format
@ -1376,16 +1343,12 @@ msgid "Cockpit"
msgstr "Cockpit"
#: plinth/modules/cockpit/manifest.py:23
#, fuzzy
#| msgid "Administration"
msgid "Advanced administration"
msgstr "Administrering"
msgstr "Avancerad administration"
#: plinth/modules/cockpit/manifest.py:24
#, fuzzy
#| msgid "Sharing"
msgid "Web terminal"
msgstr "Sharing"
msgstr "Webbterminal"
#: plinth/modules/cockpit/manifest.py:25 plinth/modules/storage/__init__.py:47
#: plinth/modules/storage/__init__.py:319
@ -1395,8 +1358,6 @@ msgid "Storage"
msgstr "Lagring"
#: plinth/modules/cockpit/manifest.py:26
#, fuzzy
#| msgid "Networks"
msgid "Networking"
msgstr "Nätverk"
@ -1407,7 +1368,7 @@ msgstr "Tjänster"
#: plinth/modules/cockpit/manifest.py:28 plinth/templates/app-logs.html:9
msgid "Logs"
msgstr ""
msgstr "Loggar"
#: plinth/modules/cockpit/manifest.py:29
#: plinth/modules/performance/__init__.py:16
@ -1416,16 +1377,12 @@ msgid "Performance"
msgstr "Prestanda"
#: plinth/modules/config/__init__.py:20
#, fuzzy
#| msgid ""
#| "Here you can set some general configuration options like hostname, domain "
#| "name, webserver home page etc."
msgid ""
"Here you can set some general configuration options like webserver home page "
"etc."
msgstr ""
"Här kan du ställa in några allmänna konfigurationsalternativ som värdnamn, "
"domännamn, webserver, hemsida etc."
"Här kan du ställa in några allmänna konfigurationsalternativ som webbserver, "
"hemsida med mera."
#: plinth/modules/config/__init__.py:42
msgid "General Configuration"
@ -1510,13 +1467,11 @@ msgstr "Hemsida"
#: plinth/modules/config/manifest.py:8
msgid "Logging"
msgstr ""
msgstr "Loggning"
#: plinth/modules/config/manifest.py:8
#, fuzzy
#| msgid "Advanced"
msgid "Advanced apps"
msgstr "Avancerat"
msgstr "Avancerade appar"
#: plinth/modules/config/views.py:41
#, python-brace-format
@ -1573,15 +1528,15 @@ msgstr "Ogiltig lista för URI:er för STUN/TURN-servrar"
#: plinth/modules/coturn/manifest.py:7 plinth/modules/janus/manifest.py:16
msgid "Video conference"
msgstr ""
msgstr "Videokonferens"
#: plinth/modules/coturn/manifest.py:7
msgid "STUN"
msgstr ""
msgstr "STUN"
#: plinth/modules/coturn/manifest.py:7
msgid "TURN"
msgstr ""
msgstr "TURN"
#: plinth/modules/coturn/templates/coturn.html:15
msgid "Use the following URLs to configure your communication server:"
@ -1625,14 +1580,10 @@ msgid "-- no time zone set --"
msgstr "-- ingen tidszon inställd --"
#: plinth/modules/datetime/manifest.py:15
#, fuzzy
#| msgid "Network Interface"
msgid "Network time"
msgstr "Nätverksgränssnitt"
msgstr "Nätverkstid"
#: plinth/modules/datetime/manifest.py:15
#, fuzzy
#| msgid "Time Zone"
msgid "Timezone"
msgstr "Tidszon"
@ -1678,24 +1629,18 @@ msgstr "Bittorrent-klient skriven i Python / PyGTK"
#: plinth/modules/deluge/manifest.py:21
#: plinth/modules/transmission/manifest.py:39
#, fuzzy
#| msgid "Deluge BitTorrent"
msgid "BitTorrent"
msgstr "Deluge BitTorrent"
msgstr "BitTorrent"
#: plinth/modules/deluge/manifest.py:21 plinth/modules/roundcube/manifest.py:23
#: plinth/modules/transmission/manifest.py:39
#, fuzzy
#| msgid "Launch web client"
msgid "Web client"
msgstr "Starta webbklient"
msgstr "Webklient"
#: plinth/modules/deluge/manifest.py:21 plinth/modules/syncthing/manifest.py:58
#: plinth/modules/transmission/manifest.py:39
#, fuzzy
#| msgid "I2P"
msgid "P2P"
msgstr "I2P"
msgstr "P2P"
#: plinth/modules/diagnostics/__init__.py:32
msgid ""
@ -1711,6 +1656,8 @@ msgid ""
"This app also shows the <a href=\"{logs_url}\">logs</a> for {box_name} "
"services."
msgstr ""
"Denna app visar också <a href=\"{logs_url}\">loggar</a> för {box_name} "
"tjänster."
#: plinth/modules/diagnostics/__init__.py:60
#: plinth/modules/diagnostics/__init__.py:255
@ -1719,7 +1666,7 @@ msgstr "Diagnostik"
#: plinth/modules/diagnostics/__init__.py:115
msgid "skipped"
msgstr ""
msgstr "Hoppat över"
#: plinth/modules/diagnostics/__init__.py:116
msgid "passed"
@ -1806,7 +1753,7 @@ msgstr "Om problem upptäcks, försök att reparera dem automatiskt."
#: plinth/modules/diagnostics/manifest.py:10
msgid "Detect problems"
msgstr ""
msgstr "Finn problem"
#: plinth/modules/diagnostics/manifest.py:10
msgid "Repair"
@ -1814,7 +1761,7 @@ msgstr "Reparera"
#: plinth/modules/diagnostics/manifest.py:10
msgid "Daily"
msgstr ""
msgstr "Daglig"
#: plinth/modules/diagnostics/templates/diagnostics.html:11
msgid "Diagnostics Run"
@ -1865,33 +1812,31 @@ msgstr "Resultat"
#: plinth/modules/diagnostics/templates/diagnostics_full.html:53
#, python-format
msgid "%(number)s passed"
msgstr ""
msgstr "%(number)s passerade"
#: plinth/modules/diagnostics/templates/diagnostics_full.html:57
#, python-format
msgid "%(number)s failed"
msgstr ""
msgstr "%(number)s misslyckades"
#: plinth/modules/diagnostics/templates/diagnostics_full.html:61
#, python-format
msgid "%(number)s warnings"
msgstr ""
msgstr "%(number)s varningar"
#: plinth/modules/diagnostics/templates/diagnostics_full.html:65
#, python-format
msgid "%(number)s errors"
msgstr ""
msgstr "%(number)s fel"
#: plinth/modules/diagnostics/templates/diagnostics_full.html:69
#, python-format
msgid "%(number)s skipped"
msgstr ""
msgstr "%(number)s skippade"
#: plinth/modules/diagnostics/templates/diagnostics_full.html:111
#, fuzzy
#| msgid "deluge-web is running"
msgid "Running..."
msgstr "Deluges webbgränssnitt är aktiverat"
msgstr "Kör..."
#: plinth/modules/diagnostics/templates/diagnostics_results.html:11
msgid "Test"

View File

@ -1,12 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""FreedomBox app to manage backup archives."""
import collections.abc
import contextlib
import json
import logging
import os
import pathlib
import re
import subprocess
from pathlib import Path
from django.utils.text import get_valid_filename
from django.utils.translation import gettext_lazy as _
@ -14,9 +16,10 @@ from django.utils.translation import gettext_noop
from plinth import app as app_module
from plinth import cfg, glib, menu
from plinth.modules.names.components import DomainName
from plinth.package import Packages
from . import api, manifest, privileged
from . import api, errors, manifest, privileged
logger = logging.getLogger(__name__)
@ -132,9 +135,77 @@ def restore_from_upload(path, app_ids=None):
create_subvolume=False, backup_file=path)
def get_known_hosts_path():
def get_known_hosts_path() -> Path:
"""Return the path to the known hosts file."""
return pathlib.Path(cfg.data_dir) / '.ssh' / 'known_hosts'
return Path(cfg.data_dir) / '.ssh' / 'known_hosts'
def get_ssh_client_auth_key_paths() -> tuple[Path, Path]:
"""Return the paths to the SSH client public key and private key."""
key_path = Path(cfg.data_dir) / '.ssh' / 'id_ed25519'
pubkey_path = key_path.with_suffix('.pub')
return pubkey_path, key_path
def generate_ssh_client_auth_key():
"""Generate SSH client authentication keypair, if needed."""
_, key_path = get_ssh_client_auth_key_paths()
key_path.parent.mkdir(parents=True, exist_ok=True)
if key_path.exists():
logger.info('SSH client key %s for FreedomBox service already exists',
key_path)
return
domain_name = DomainName.list_names()[0]
logger.info('Generating SSH client key %s for FreedomBox service',
key_path)
subprocess.run([
'ssh-keygen', '-t', 'ed25519', '-N', '', '-C',
f'freedombox@{domain_name}', '-f',
str(key_path)
], stdout=subprocess.DEVNULL, check=True)
def get_ssh_client_public_key() -> str:
"""Get SSH client public key for FreedomBox service."""
pubkey_path, _ = get_ssh_client_auth_key_paths()
with pubkey_path.open('r') as pubkey_file:
pubkey = pubkey_file.read()
return pubkey
def copy_ssh_client_public_key(pubkey_path: str, hostname: str, username: str,
password: str):
"""Copy the SSH client public key to the remote server.
Returns whether the copy was successful, and any error message.
"""
env = os.environ.copy()
env['SSHPASS'] = str(password)
with raise_ssh_error():
try:
subprocess.run([
'sshpass', '-e', 'ssh-copy-id', '-i', pubkey_path,
f'{username}@{hostname}'
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True,
env=env)
logger.info("Copied SSH client public key to remote host's "
"authorized keys.")
except subprocess.CalledProcessError as exception:
logger.warning('Failed to copy SSH client public key: %s',
exception.stderr)
raise
@contextlib.contextmanager
def raise_ssh_error() -> collections.abc.Generator[None]:
"""Convert subprocess error to SshError."""
try:
yield
except subprocess.CalledProcessError as exception:
raise errors.SshError(exception.returncode, exception.cmd,
exception.output, exception.stderr)
def is_ssh_hostkey_verified(hostname):

View File

@ -1,18 +1,24 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import subprocess
from plinth.errors import PlinthError
class BorgError(PlinthError):
"""Generic borg errors"""
"""Generic borg errors."""
class BorgRepositoryDoesNotExistError(BorgError):
"""Borg access to a repository works but the repository does not exist"""
"""Borg access to a repository works but the repository does not exist."""
class SshError(subprocess.CalledProcessError):
"""Error when running an SSH command."""
class SshfsError(PlinthError):
"""Generic sshfs errors"""
"""Generic sshfs errors."""
class BorgRepositoryExists(BorgError):

View File

@ -184,7 +184,7 @@ class EncryptedBackupsMixin(forms.Form):
choices=[('repokey', _('Key in Repository')), ('none', _('None'))])
encryption_passphrase = forms.CharField(
label=_('Passphrase'),
help_text=_('Passphrase; Only needed when using encryption.'),
help_text=_('Only needed when using encryption.'),
widget=forms.PasswordInput(), required=False)
confirm_encryption_passphrase = forms.CharField(
label=_('Confirm Passphrase'), help_text=_('Repeat the passphrase.'),
@ -251,13 +251,30 @@ class AddRemoteRepositoryForm(EncryptedBackupsMixin, forms.Form):
help_text=_('Path of a new or existing repository. Example: '
'<i>user@host:~/path/to/repo/</i>'),
validators=[repository_validator])
ssh_auth_type = forms.ChoiceField(
label=_('SSH Authentication Type'),
help_text=_('Choose how to authenticate to the remote SSH server.'),
widget=forms.RadioSelect(),
choices=[('key_auth', _('Key-based Authentication')),
('password_auth', _('Password-based Authentication'))])
ssh_password = forms.CharField(
label=_('SSH server password'), strip=True,
help_text=_('Password of the SSH Server.<br />'
'SSH key-based authentication is not yet possible.'),
widget=forms.PasswordInput(), required=False)
label=_('SSH server password'), widget=forms.PasswordInput(),
strip=True, help_text=_('Required for password-based authentication.'),
required=False)
field_order = ['repository', 'ssh_password'] + encryption_fields
field_order = ['repository', 'ssh_auth_type', 'ssh_password'
] + encryption_fields
def clean(self):
"""Perform additional checks on form data."""
super().clean()
ssh_password = self.cleaned_data.get('ssh_password')
if self.cleaned_data.get(
'ssh_auth_type') == 'password_auth' and not ssh_password:
raise forms.ValidationError(
_('SSH password is needed for password-based authentication.'))
return self.cleaned_data
def clean_repository(self):
"""Validate repository form field."""

View File

@ -49,7 +49,7 @@ KNOWN_ERRORS = [
'raise_as': errors.BorgRepositoryDoesNotExistError,
},
{
'errors': ['passphrase supplied in .* is incorrect'],
'errors': ['[Pp]assphrase supplied in .* is incorrect'],
'message': _('Incorrect encryption passphrase'),
'raise_as': errors.BorgError,
},
@ -131,8 +131,7 @@ def _reraise_known_errors(err):
@reraise_known_errors
@privileged
def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
password: secret_str | None = None,
def mount(mountpoint: str, remote_path: str, ssh_keyfile: str,
user_known_hosts_file: str = '/dev/null'):
"""Mount a remote ssh path via sshfs."""
try:
@ -156,16 +155,9 @@ def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
'sshfs', remote_path, mountpoint, '-o',
f'UserKnownHostsFile={user_known_hosts_file}', '-o',
'StrictHostKeyChecking=yes', '-o', 'reconnect', '-o',
'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'
'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', '-o',
'IdentityFile=' + ssh_keyfile
]
if ssh_keyfile:
cmd += ['-o', 'IdentityFile=' + ssh_keyfile]
else:
if not password:
raise ValueError('mount requires either a password or ssh_keyfile')
cmd += ['-o', 'password_stdin']
input_ = password.encode()
action_utils.run(cmd, check=True, timeout=TIMEOUT, input=input_)

View File

@ -13,7 +13,9 @@ from django.utils.translation import gettext_lazy as _
from plinth import cfg
from plinth.utils import format_lazy
from . import (_backup_handler, api, errors, get_known_hosts_path, privileged,
from . import (_backup_handler, api, copy_ssh_client_public_key, errors,
generate_ssh_client_auth_key, get_known_hosts_path,
get_ssh_client_auth_key_paths, privileged, raise_ssh_error,
restore_archive_handler, split_path, store)
from .schedule import Schedule
@ -143,6 +145,9 @@ class BaseBorgRepository(abc.ABC):
privileged.delete_archive(archive_path,
self._get_encryption_passpharse())
def migrate_credentials(self) -> None:
"""Migrate any credentials."""
def initialize(self):
"""Initialize / create a borg repository."""
encryption = 'none'
@ -315,11 +320,27 @@ class SshBorgRepository(BaseBorgRepository):
self._umount_ignore_errors()
@property
def hostname(self):
def hostname(self) -> str:
"""Return hostname from the remote path."""
_, hostname, _ = split_path(self._path)
return hostname.split('%')[0] # XXX: Likely incorrect to split
@property
def username(self) -> str:
"""Return username from the remote path."""
username, _, _ = split_path(self._path)
return username
@property
def ssh_password(self) -> str | None:
"""Return SSH password if it is stored, otherwise None."""
return self.credentials.get('ssh_password')
@property
def ssh_keyfile(self) -> str | None:
"""Return path to SSH client key if stored, otherwise None."""
return self.credentials.get('ssh_keyfile')
@property
def _mountpoint(self):
"""Return the local mount point where repository is to be mounted."""
@ -330,8 +351,22 @@ class SshBorgRepository(BaseBorgRepository):
"""Return whether remote path is mounted locally."""
return privileged.is_mounted(self._mountpoint)
def migrate_credentials(self) -> None:
"""Add SSH keyfile credential and delete stored password."""
if not self.ssh_password:
return
pubkey_path, keyfile_path = get_ssh_client_auth_key_paths()
generate_ssh_client_auth_key()
copy_ssh_client_public_key(str(pubkey_path), self.hostname,
self.username, self.ssh_password)
self.credentials['ssh_keyfile'] = str(keyfile_path)
self.credentials.pop('ssh_password', None)
self.save()
def initialize(self):
"""Initialize the repository after mounting the target directory."""
self.migrate_credentials()
self._ensure_remote_directory()
self.mount()
super().initialize()
@ -341,17 +376,11 @@ class SshBorgRepository(BaseBorgRepository):
if self.is_mounted:
return
self.migrate_credentials()
known_hosts_path = get_known_hosts_path()
kwargs = {'user_known_hosts_file': str(known_hosts_path)}
if 'ssh_password' in self.credentials and self.credentials[
'ssh_password']:
kwargs['password'] = self.credentials['ssh_password']
if 'ssh_keyfile' in self.credentials and self.credentials[
'ssh_keyfile']:
kwargs['ssh_keyfile'] = self.credentials['ssh_keyfile']
privileged.mount(self._mountpoint, self._path, **kwargs)
privileged.mount(self._mountpoint, self._path,
ssh_keyfile=self.credentials['ssh_keyfile'],
user_known_hosts_file=str(known_hosts_path))
def umount(self):
"""Unmount the remote path that was mounted locally using sshfs."""
@ -391,16 +420,16 @@ class SshBorgRepository(BaseBorgRepository):
if dir_path[0] == '~':
dir_path = '.' + dir_path[1:]
password = self.credentials['ssh_password']
# Ensure remote directory exists, check contents
env = {'SSHPASS': password}
known_hosts_path = str(get_known_hosts_path())
subprocess.run([
'sshpass', '-e', 'ssh', '-o',
f'UserKnownHostsFile={known_hosts_path}', f'{username}@{hostname}',
'mkdir', '-p', dir_path
], check=True, env=env)
with raise_ssh_error():
subprocess.run([
'ssh', '-i',
str(self.ssh_keyfile), '-o',
f'UserKnownHostsFile={known_hosts_path}', '-o',
'BatchMode=yes', f'{username}@{hostname}', 'mkdir', '-p',
dir_path
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
def get_repositories():

View File

@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* This file is part of FreedomBox.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
document.addEventListener('DOMContentLoaded', () => {
const keyAuth = document.getElementById('id_ssh_auth_type_0');
const passwordAuth = document.getElementById('id_ssh_auth_type_1');
const sshPasswordField = document.getElementById('id_ssh_password');
const encryptionType = document.getElementById('id_encryption');
const encryptionPassphraseField = document.getElementById('id_encryption_passphrase');
const encryptionConfirmPassphraseField = document.getElementById('id_confirm_encryption_passphrase');
function handleAuthTypeChange() {
if (passwordAuth.checked) {
sshPasswordField.parentElement.parentElement.style.display = 'block';
} else {
sshPasswordField.parentElement.parentElement.style.display = 'none';
}
}
function handleEncryptionTypeChange() {
let display = 'none';
if (encryptionType.value === "repokey") {
display = 'block';
}
encryptionPassphraseField.parentElement.parentElement.style.display = display;
encryptionConfirmPassphraseField.parentElement.parentElement.style.display = display;
}
keyAuth.addEventListener('change', handleAuthTypeChange);
passwordAuth.addEventListener('change', handleAuthTypeChange);
encryptionType.addEventListener('change', handleEncryptionTypeChange);
handleAuthTypeChange();
handleEncryptionTypeChange();
});

View File

@ -5,15 +5,125 @@
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block page_js %}
<script type="text/javascript" src="{% static 'backups/backups_add_remote_repository.js' %}"
defer></script>
{% endblock %}
{% block content %}
<h3>{{ title }}</h3>
<form class="form" method="post">
<form class="form form-add-remote-repository" method="post">
{% if form.non_field_errors %}
<div class="alert alert-danger">
<a class="close" data-dismiss="alert">&times;</a>
{% for non_field_error in form.non_field_errors %}
{{ non_field_error }}
{% endfor %}
</div>
{% endif %}
{% csrf_token %}
{{ form|bootstrap }}
<div class="form-group{% if form.repository.errors %}
has-error{% endif %}">
<label class="control-label fs-4 fw-medium" for="id_repository">
{{ form.repository.label }}
</label>
<div>
<input type="text" name="repository" class="form-control"
required id="id_repository">
{% for error in form.repository.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
<p class="help-block">
{{ form.repository.help_text|safe }}
</p>
</div>
</div>
<div class="form-group{% if form.ssh_auth_type.errors %}
has-error{% endif %}">
<label class="control-label fs-4 fw-medium">
{{ form.ssh_auth_type.label }}
</label>
<p class="help-block">
{{ form.ssh_auth_type.help_text|safe }}
</p>
{% for error in form.ssh_auth_type.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
<div class="radio">
<label>
{{ form.ssh_auth_type.0.tag }}
{{ form.ssh_auth_type.0.choice_label }}
</label>
</div>
<div>
<p>
{% blocktrans trimmed %}
The following SSH client public key must be added to the
authorized keys list on the remote machine for {{ box_name }} to
be able to connect to the remote machine:
{% endblocktrans %}
</p>
<pre>{{ ssh_client_public_key }}</pre>
</div>
<div class="radio">
<label>
{{ form.ssh_auth_type.1.tag }}
{{ form.ssh_auth_type.1.choice_label }}
</label>
</div>
<p>
{% blocktrans trimmed %}
{{ box_name }} service will attempt to connect using the provided
password. If successful, then the public key will be automatically
added to the authorized keys list, so that future connections do
not need the password.
{% endblocktrans %}
</p>
{% with _=form.ssh_password|add_input_classes %}
{% include "bootstrapform/field.html" with field=form.ssh_password %}
{% endwith %}
</div>
<div class="form-group">
<div class="form-group{% if form.encryption.errors %}
has-error{% endif %}">
<label class="control-label fs-4 fw-medium" for="id_encryption">
{{ form.encryption.label }}
</label>
<div>
<select name="encryption" class="form-control" id="id_encryption">
<option value="repokey">{{ form.encryption.0.choice_label }}</option>
<option value="none">{{ form.encryption.1.choice_label }}</option>
</select>
{% for error in form.encryption.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
<p class="help-block">
{{ form.encryption.help_text|safe }}
</p>
</div>
</div>
{% with _=form.encryption_passphrase|add_input_classes %}
{% include "bootstrapform/field.html" with field=form.encryption_passphrase %}
{% endwith %}
{% with _=form.confirm_encryption_passphrase|add_input_classes %}
{% include "bootstrapform/field.html" with field=form.confirm_encryption_passphrase %}
{% endwith %}
</div>
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div class="me-2">
@ -24,8 +134,8 @@
{% blocktrans trimmed %}
The credentials for this repository are stored on your {{ box_name }}.
<br />
To restore a backup on a new {{ box_name }} you need the SSH credentials
and, if chosen, the encryption passphrase.
To restore a backup on a new {{ box_name }} you need the SSH
credentials and, if chosen, the encryption passphrase.
{% endblocktrans %}
</div>
</div>

View File

@ -4,8 +4,8 @@
{% load i18n %}
<div class="table-responsive">
<table class="table" id="archives-list">
<div class="table-responsive repository" data-repository-name="{{ repository.name }}">
<table class="table" class="archives-list">
<thead>
<tr>
<th colspan="2">
@ -23,7 +23,7 @@
</span>
{% endif %}
{{ repository.name }}
<span class="repository-name">{{ repository.name }}</span>
</div>
<div class="text-end">

View File

@ -24,7 +24,7 @@
</p>
<p>
<form class="form" method="post">
<form class="form form-remove-location" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-danger"
@ -33,4 +33,3 @@
</p>
{% endblock %}

View File

@ -10,7 +10,7 @@
<h3>{{ title }}</h3>
<form class="form" method="post">
<form class="form form-verify-ssh-hostkey" method="post">
{% csrf_token %}
{% if form.ssh_public_key|length == 0 %}

View File

@ -15,6 +15,8 @@ from plinth.tests import functional
pytestmark = [pytest.mark.system, pytest.mark.backups]
REMOTE_PATH = 'tester@localhost:~/backups'
@pytest.fixture(scope='module', autouse=True)
def fixture_background(session_browser):
@ -66,6 +68,22 @@ def test_set_schedule(session_browser):
monthly=30, run_at=15, without_app='firewall')
def test_remote_backup_location(session_browser):
"""Test remote backup location operations."""
_add_remote_backup_location(session_browser)
assert _has_remote_backup_location(session_browser)
_remove_remote_backup_location(session_browser)
assert not _has_remote_backup_location(session_browser)
# Add it again without providing SSH password.
_add_remote_backup_location(session_browser, False)
assert _has_remote_backup_location(session_browser)
_remove_remote_backup_location(session_browser)
assert not _has_remote_backup_location(session_browser)
def _assert_main_page_is_shown(session_browser):
assert (session_browser.url.endswith('/plinth/'))
@ -172,6 +190,7 @@ def _download_file_logged_in(browser, url, suffix=''):
def _download(browser, archive_name=None):
"""Download a backup archive to a temporary file on disk."""
functional.nav_to_module(browser, 'backups')
href = f'/plinth/sys/backups/root/download/{archive_name}/'
url = functional.base_url + href
@ -180,11 +199,13 @@ def _download(browser, archive_name=None):
def _open_main_page(browser):
"""Open the FreedomBox interface main page."""
with functional.wait_for_page_update(browser):
browser.links.find_by_href('/plinth/').first.click()
def _upload_and_restore(browser, app_name, downloaded_file_path):
"""Upload a backup archive from the disk and perform restore operation."""
functional.nav_to_module(browser, 'backups')
with functional.wait_for_page_update(browser):
browser.links.find_by_href('/plinth/sys/backups/upload/').first.click()
@ -197,3 +218,52 @@ def _upload_and_restore(browser, app_name, downloaded_file_path):
with functional.wait_for_page_update(browser,
expected_url='/plinth/sys/backups/'):
functional.submit(browser, form_class='form-restore')
def _has_remote_backup_location(browser) -> bool:
"""Return whether atleast one remote backup location is configured."""
functional.nav_to_module(browser, 'backups')
return browser.is_element_present_by_css(
f'.repository[data-repository-name="{REMOTE_PATH}"]')
def _add_remote_backup_location(browser, ssh_use_password=True):
"""Add a remote backup location."""
if _has_remote_backup_location(browser):
_remove_remote_backup_location(browser)
browser.links.find_by_href(
'/plinth/sys/backups/repositories/add-remote/').first.click()
browser.find_by_name('repository').fill(REMOTE_PATH)
password = functional.get_password(
functional.config['DEFAULT']['username'])
if ssh_use_password:
browser.find_by_id('id_ssh_auth_type_1').check()
browser.find_by_name('ssh_password').fill(password)
else:
browser.find_by_id('id_ssh_auth_type_0').check()
browser.choose('id_encryption', 'repokey')
browser.find_by_name('encryption_passphrase').fill(password)
browser.find_by_name('confirm_encryption_passphrase').fill(password)
functional.submit(browser, form_class='form-add-remote-repository')
assert browser.is_text_present('Added new remote SSH repository.')
if 'ssh-verify' in browser.url:
_verify_host_key(browser)
def _remove_remote_backup_location(browser):
"""Remove the remote backup location with known remote path."""
repository = browser.find_by_css(
f'.repository[data-repository-name="{REMOTE_PATH}"]').first
repository.find_by_css('.repository-remove').first.click()
functional.submit(browser, form_class='form-remove-location')
def _verify_host_key(browser):
"""Verify the remote location's SSH host key."""
browser.find_by_name('ssh_public_key').first.click()
functional.submit(browser, form_class='form-verify-ssh-hostkey')
assert browser.is_text_present('SSH host verified.')

View File

@ -6,7 +6,6 @@ Views for the backups app.
import contextlib
import logging
import os
import subprocess
from urllib.parse import unquote
from django.contrib import messages
@ -24,7 +23,9 @@ from plinth.errors import PlinthError
from plinth.modules import backups, storage
from plinth.views import AppView
from . import (SESSION_PATH_VARIABLE, api, errors, forms, get_known_hosts_path,
from . import (SESSION_PATH_VARIABLE, api, errors, forms,
generate_ssh_client_auth_key, get_known_hosts_path,
get_ssh_client_auth_key_paths, get_ssh_client_public_key,
is_ssh_hostkey_verified, privileged)
from .decorators import delete_tmp_backup_file
from .repository import (BorgRepository, SshBorgRepository, get_instance,
@ -38,6 +39,19 @@ def handle_common_errors(request: HttpRequest):
"""If any known Borg exceptions occur, show proper error messages."""
try:
yield
except errors.SshError as exception:
if exception.returncode in (6, 7):
message = _('SSH host public key could not be verified.')
elif (exception.returncode == 5
or 'Permission denied' in exception.stderr.decode()):
message = _('Authentication to remote server failed.')
else:
message = _(
'Error establishing connection to server: {} {} {}').format(
str(exception), exception.stdout.decode(),
exception.stderr.decode())
messages.error(request, message)
except errors.BorgError as exception:
messages.error(request, exception.args[0])
@ -344,11 +358,11 @@ class AddRepositoryView(FormView):
encryption_passphrase = None
credentials = {'encryption_passphrase': encryption_passphrase}
repository = BorgRepository(path, credentials)
with handle_common_errors(self.request):
repository = BorgRepository(path, credentials)
if _save_repository(self.request, repository):
messages.success(self.request, _('Added new repository.'))
return super().form_valid(form)
_save_repository(self.request, repository)
messages.success(self.request, _('Added new repository.'))
return super().form_valid(form)
return redirect(reverse_lazy('backups:add-repository'))
@ -358,10 +372,19 @@ class AddRemoteRepositoryView(FormView):
form_class = forms.AddRemoteRepositoryForm
template_name = 'backups_add_remote_repository.html'
def get(self, *args, **kwargs):
"""Handle GET requests.
Generate SSH client authentication key if necessary.
"""
generate_ssh_client_auth_key()
return super().get(*args, kwargs)
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Create remote backup repository')
context['ssh_client_public_key'] = get_ssh_client_public_key()
return context
def form_valid(self, form):
@ -374,10 +397,13 @@ class AddRemoteRepositoryView(FormView):
if form.cleaned_data.get('encryption') == 'none':
encryption_passphrase = None
credentials = {
'ssh_password': form.cleaned_data.get('ssh_password'),
'encryption_passphrase': encryption_passphrase
}
credentials = {'encryption_passphrase': encryption_passphrase}
if form.cleaned_data.get('ssh_auth_type') == 'password_auth':
credentials['ssh_password'] = form.cleaned_data.get('ssh_password')
else:
_pubkey_path, key_path = get_ssh_client_auth_key_paths()
credentials['ssh_keyfile'] = str(key_path)
with handle_common_errors(self.request):
repository = SshBorgRepository(path, credentials)
repository.verfied = False
@ -426,27 +452,28 @@ class VerifySshHostkeyView(FormView):
with known_hosts_path.open('a', encoding='utf-8') as known_hosts_file:
known_hosts_file.write(ssh_public_key + '\n')
def _save_repository_and_redirect(self):
"""Save the repository and redirect according to the result."""
with handle_common_errors(self.request):
_save_repository(self.request, self._get_repository())
return redirect(reverse_lazy('backups:index'))
return redirect(reverse_lazy('backups:add-remote-repository'))
def get(self, *args, **kwargs):
"""Skip this view if host is already verified."""
if not is_ssh_hostkey_verified(self._get_repository().hostname):
return super().get(*args, **kwargs)
messages.success(self.request, _('SSH host already verified.'))
if _save_repository(self.request, self._get_repository()):
return redirect(reverse_lazy('backups:index'))
return redirect(reverse_lazy('backups:add-remote-repository'))
return self._save_repository_and_redirect()
def form_valid(self, form):
"""Create and store the repository."""
ssh_public_key = form.cleaned_data['ssh_public_key']
with handle_common_errors(self.request):
self._add_ssh_hostkey(ssh_public_key)
messages.success(self.request, _('SSH host verified.'))
if _save_repository(self.request, self._get_repository()):
return redirect(reverse_lazy('backups:index'))
return redirect(reverse_lazy('backups:add-remote-repository'))
self._add_ssh_hostkey(ssh_public_key)
messages.success(self.request, _('SSH host verified.'))
return self._save_repository_and_redirect()
def _save_repository(request, repository):
@ -455,29 +482,16 @@ def _save_repository(request, repository):
repository.initialize()
repository.verified = True
repository.save()
return True
except subprocess.CalledProcessError as exception:
if exception.returncode in (6, 7):
message = _('SSH host public key could not be verified.')
elif exception.returncode == 5:
message = _('Authentication to remote server failed.')
else:
message = _('Error establishing connection to server: {}').format(
str(exception))
except Exception as exception:
message = str(exception)
logger.exception('Error adding repository: %s', exception)
except Exception:
# Remove the repository so that the user can have another go at
# creating it.
try:
repository.remove()
messages.error(request, _('Repository removed.'))
except KeyError:
pass
messages.error(request, message)
# Remove the repository so that the user can have another go at
# creating it.
try:
repository.remove()
messages.error(request, _('Repository removed.'))
except KeyError:
pass
return False
raise
class RemoveRepositoryView(TemplateView):
@ -522,7 +536,8 @@ def mount_repository(request, uuid):
repository = SshBorgRepository.load(uuid)
try:
repository.mount()
with handle_common_errors(request):
repository.mount()
except Exception as err:
msg = "%s: %s" % (_('Mounting failed'), str(err))
messages.error(request, msg)

View File

@ -102,6 +102,7 @@ class ShowClientView(SuccessMessageMixin, TemplateView):
context['endpoints'] = [
domain + ':' + str(server_info['listen_port'])
for domain in domains
if not domain.endswith('.local')
]
return context

View File

@ -260,7 +260,6 @@ def _shutdown_server() -> None:
This must be run in a thread separate from the server.serve_forever()
otherwise it will deadlock waiting for the shutdown to complete.
"""
global _server
logger.info('SIGTERM received, shutting down the server.')
if _server:
_server.shutdown()