mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
fix(accounts): an OTP mail links to the QR code instead of holding its secret
The Lost OTP? page, open without a login, and the admin's Email OTP QR code mailed the QR code itself: the TOTP secret then stayed in the mailbox and on every server the mail passed. The mail now carries a signed link, good for an hour, to a page that shows the QR code (not cached); the page shows nothing for an old or altered link or a user without a device. tr and zh too.
This commit is contained in:
parent
c62de11147
commit
c42f74b4ee
12 changed files with 314 additions and 279 deletions
|
|
@ -454,6 +454,8 @@ An LDAP user is authenticated by LDAP and authorized through the WebVirtCloud pe
|
|||
|
||||
Set `OTP_ENABLED = True` in `settings.py` to ask for a one-time code at login. It is per user: a user without a confirmed device signs in with the password alone.
|
||||
|
||||
The QR code is mailed as a link to a page that shows it, good for one hour: the mail does not hold the code's secret. Email OTP QR code on a user's page (Users) sends it; Lost OTP? on the login page sends it to an enrolled user who gives their address.
|
||||
|
||||
### Serial console
|
||||
|
||||
A VM without graphics (no `<graphics>` device, a `<console type='pty'>`) opens a serial console (xterm.js) instead of VNC. It is off by default. To turn it on, set `WEBVIRTCLOUD_SERIAL_CONSOLE=1` (Docker: in `docker-compose.override.yml`, then `docker compose up -d`) or `SERIAL_CONSOLE_ENABLED = True` in `settings.py` and `systemctl restart webvirt-cloud webvirt-socketiod`.
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
{% load i18n %}
|
||||
{% blocktrans %}
|
||||
Scan this QR code to get OTP for account '{{ user }}'
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ qr_svg }}
|
||||
<p class="small">{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}</p>
|
||||
<img src="{{ qr_png }}" alt="{{ totp_url }}">
|
||||
15
accounts/templates/accounts/otp_qr.html
Normal file
15
accounts/templates/accounts/otp_qr.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "OTP QR Code" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "OTP QR Code" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p>{% blocktrans %}Scan this QR code to get OTP for account '{{ user }}'{% endblocktrans %}</p>
|
||||
{{ qr_svg }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
|
@ -2,17 +2,19 @@ import base64
|
|||
import hashlib
|
||||
import re
|
||||
import struct
|
||||
import zlib
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from lxml import html
|
||||
|
||||
from accounts.utils import (get_existing_totp_device, get_user_totp_device, send_email_with_otp, validate_ssh_key)
|
||||
from accounts.utils import (OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg,
|
||||
send_email_with_otp, validate_ssh_key)
|
||||
|
||||
|
||||
class AccountsUtilsTestCase(TestCase):
|
||||
|
|
@ -80,32 +82,30 @@ class AccountsUtilsTestCase(TestCase):
|
|||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
self.assertEqual(get_existing_totp_device(self.user).id, device.id)
|
||||
|
||||
def test_send_email_with_otp(self):
|
||||
@override_settings(OTP_ENABLED=True)
|
||||
def test_the_mail_links_to_the_qr_code_and_holds_no_secret(self):
|
||||
device = get_user_totp_device(self.user)
|
||||
send_email_with_otp(self.user, device)
|
||||
send_email_with_otp(RequestFactory().get("/"), self.user)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn("OTP QR Code", mail.outbox[0].subject)
|
||||
self.assertEqual(mail.outbox[0].to, ["user@example.com"])
|
||||
# the QR code as inline SVG and, for clients without SVG, as a PNG data URI
|
||||
doc = html.fromstring(mail.outbox[0].alternatives[0][0])
|
||||
self.assertEqual(len(doc.xpath("//svg")), 1)
|
||||
(img,) = doc.xpath("//img")
|
||||
self.assertEqual(img.get("alt"), device.config_url)
|
||||
self.assertTrue(base64.b64decode(img.get("src").removeprefix("data:image/png;base64,")).startswith(b"\x89PNG"))
|
||||
message = mail.outbox[0]
|
||||
self.assertEqual((message.subject, message.to, message.alternatives), ("OTP QR Code", ["user@example.com"], []))
|
||||
self.assertNotIn(device.bin_key.hex(), message.body)
|
||||
self.assertNotIn("otpauth", message.body)
|
||||
link = re.search(r"http://testserver(/\S+)", message.body).group(1)
|
||||
page = self.client.get(link)
|
||||
self.assertEqual(page["Cache-Control"], "no-store")
|
||||
self.assertEqual(len(html.fromstring(page.content).xpath("//svg")), 1)
|
||||
|
||||
def test_otp_email_qr_codes_are_what_django_qr_code_drew(self):
|
||||
# sha256 of the SVG and the PNG django-qr-code 4.2.0 made for this URL
|
||||
@override_settings(OTP_ENABLED=True)
|
||||
def test_a_link_that_is_old_or_not_signed_shows_nothing(self):
|
||||
get_user_totp_device(self.user)
|
||||
token = OTP_LINK.sign(str(self.user.pk))
|
||||
with patch("django.core.signing.time.time", return_value=time.time() + OTP_LINK_MAX_AGE + 1):
|
||||
self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[token])).status_code, 404)
|
||||
self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[f"{self.user.pk}:x:y"])).status_code, 404)
|
||||
|
||||
def test_the_qr_code_is_what_django_qr_code_drew(self):
|
||||
# sha256 of the SVG django-qr-code 4.2.0 made for this URL
|
||||
url = "otpauth://totp/WebVirtCloud:alice?secret=JBSWY3DPEHPK3PXP&issuer=WebVirtCloud"
|
||||
with patch("accounts.utils.send_mail") as send:
|
||||
send_email_with_otp(self.user, SimpleNamespace(config_url=url))
|
||||
page = send.call_args.kwargs["html_message"]
|
||||
svg = re.search(r"<svg.*?</svg>", page, re.S).group(0)
|
||||
png = base64.b64decode(re.search(r'<img src="data:image/png;base64,([^"]+)"', page).group(1))
|
||||
svg = str(otp_qr_svg(SimpleNamespace(config_url=url)))
|
||||
self.assertEqual(hashlib.sha256(svg.encode()).hexdigest(), "b57323be725891af13d862a3a3351cac3ac64a493793c0258cf3e4a5886467c2")
|
||||
# the PNG's header and pixel rows: its compressed bytes depend on the zlib build
|
||||
chunks, pos = {}, 8
|
||||
while pos < len(png):
|
||||
size, kind = struct.unpack(">I4s", png[pos:pos + 8])
|
||||
chunks[kind] = chunks.get(kind, b"") + png[pos + 8:pos + 8 + size]
|
||||
pos += 12 + size
|
||||
self.assertEqual(hashlib.sha256(chunks[b"IHDR"] + zlib.decompress(chunks[b"IDAT"])).hexdigest(), "cca16de611c73468827f70001f69dc2e48633e6aae2b570a9391eb40a1f8ae88")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ urlpatterns = [
|
|||
path("api_token/created/", views.api_token_created, name="api_token_created"),
|
||||
path("api_token/<str:token_key>/delete/", views.api_token_delete, name="api_token_delete"),
|
||||
path("email_otp/", views.email_otp, name="email_otp"),
|
||||
path("otp_qr/<str:token>/", views.otp_qr, name="otp_qr"),
|
||||
path(
|
||||
"admin_email_otp/<int:user_id>/",
|
||||
views.admin_email_otp,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from decimal import Decimal
|
|||
|
||||
import segno
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_otp import devices_for_user
|
||||
|
|
@ -56,24 +57,26 @@ def validate_ssh_key(key):
|
|||
return True
|
||||
|
||||
|
||||
def send_email_with_otp(user, device):
|
||||
# An inline SVG (1.8 mm modules) and, for mail clients that show no SVG,
|
||||
# a PNG (12 px modules); error correction M, 4-module border.
|
||||
qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False)
|
||||
style = {"border": 4, "dark": "black", "light": "white"}
|
||||
# the link a mail carries to its user's OTP QR code: the mail holds no secret
|
||||
OTP_LINK = TimestampSigner(salt="accounts.otp_qr")
|
||||
OTP_LINK_MAX_AGE = 3600
|
||||
|
||||
|
||||
def send_email_with_otp(request, user):
|
||||
"""Mail the user a link to the page of their OTP QR code, good for
|
||||
OTP_LINK_MAX_AGE: the QR code's secret is never in a mailbox."""
|
||||
url = request.build_absolute_uri(reverse("accounts:otp_qr", args=[OTP_LINK.sign(str(user.pk))]))
|
||||
send_mail(
|
||||
_("OTP QR Code"),
|
||||
_("Please view HTML version of this message."),
|
||||
_("Open this link within an hour to see the OTP QR code of account '%(user)s':\n\n%(url)s")
|
||||
% {"user": user, "url": url},
|
||||
None,
|
||||
[user.email],
|
||||
html_message=render_to_string(
|
||||
"accounts/email/otp.html",
|
||||
{
|
||||
"totp_url": device.config_url,
|
||||
"user": user,
|
||||
"qr_svg": mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", **style)),
|
||||
"qr_png": qr.png_data_uri(scale=12, **style),
|
||||
},
|
||||
),
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
def otp_qr_svg(device):
|
||||
"""The device's QR code as inline SVG: 1.8 mm modules, error correction M, a 4-module border."""
|
||||
qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False)
|
||||
return mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", border=4, dark="black", light="white"))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model, update_session_auth_hash
|
|||
from django.contrib.auth.decorators import login_not_required, login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.signing import BadSignature
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
|
|
@ -28,7 +29,7 @@ from accounts.forms import ApiTokenForm, EmailOTPForm, ProfileForm, UserSSHKeyFo
|
|||
from accounts.models import ApiToken, User, UserAttributes, UserInstance, UserSSHKey
|
||||
|
||||
from . import forms
|
||||
from .utils import get_existing_totp_device, get_user_totp_device, send_email_with_otp
|
||||
from .utils import OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg, send_email_with_otp
|
||||
|
||||
|
||||
def profile(request):
|
||||
|
|
@ -301,7 +302,7 @@ EMAIL_OTP_INTERVAL = 300
|
|||
@login_not_required
|
||||
def email_otp(request):
|
||||
"""
|
||||
"Lost OTP?": mail the OTP QR code to the user with this address. Open to
|
||||
"Lost OTP?": mail a link to the OTP QR code to the user with this address. Open to
|
||||
anyone, so it mails only one exact, active match, at most once per
|
||||
EMAIL_OTP_INTERVAL, and answers the same in every case.
|
||||
"""
|
||||
|
|
@ -318,7 +319,7 @@ def email_otp(request):
|
|||
device = get_existing_totp_device(user)
|
||||
if device is not None:
|
||||
try:
|
||||
send_email_with_otp(user, device)
|
||||
send_email_with_otp(request, user)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Could not mail the OTP QR code to user %s", user.pk)
|
||||
|
||||
|
|
@ -337,6 +338,24 @@ def email_otp(request):
|
|||
)
|
||||
|
||||
|
||||
@login_not_required
|
||||
def otp_qr(request, token):
|
||||
"""The OTP QR code of the user a mailed link (send_email_with_otp) names."""
|
||||
if not settings.OTP_ENABLED:
|
||||
raise Http404
|
||||
try:
|
||||
user_id = OTP_LINK.unsign(token, max_age=OTP_LINK_MAX_AGE)
|
||||
except BadSignature:
|
||||
raise Http404
|
||||
user = get_object_or_404(get_user_model(), pk=user_id, is_active=True)
|
||||
device = get_existing_totp_device(user)
|
||||
if device is None:
|
||||
raise Http404
|
||||
response = render(request, "accounts/otp_qr.html", {"user": user, "qr_svg": otp_qr_svg(device)})
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
@superuser_only
|
||||
def admin_email_otp(request, user_id):
|
||||
|
|
@ -345,7 +364,8 @@ def admin_email_otp(request, user_id):
|
|||
if user.email != "":
|
||||
try:
|
||||
with audit(request, None, subject, "mail OTP QR code"):
|
||||
send_email_with_otp(user, get_user_totp_device(user))
|
||||
get_user_totp_device(user)
|
||||
send_email_with_otp(request, user)
|
||||
except OSError as err: # the SMTP server refused it, or did not answer within EMAIL_TIMEOUT
|
||||
messages.error(request, _("The OTP QR code could not be mailed to %(user)s: %(error)s") % {
|
||||
"user": user, "error": err})
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -11,7 +11,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-11-05 13:37+0000\n"
|
||||
"POT-Creation-Date: 2026-11-05 14:27+0000\n"
|
||||
"PO-Revision-Date: 2020-11-04 12:00+0000\n"
|
||||
"Last-Translator: catborise <muhammetalisag@gmail.com>, 2020\n"
|
||||
"Language-Team: Turkish (https://www.transifex.com/catborise/teams/110663/tr/)\n"
|
||||
|
|
@ -314,19 +314,6 @@ msgstr "İptal"
|
|||
msgid "Change password"
|
||||
msgstr "Parolayı değiştir"
|
||||
|
||||
#: accounts/templates/accounts/email/otp.html:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Scan this QR code to get OTP for account '%(user)s'\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"'%(user)s' hesabı için bu OTP QR kodunu tarayın\n"
|
||||
|
||||
#: accounts/templates/accounts/email/otp.html:7
|
||||
msgid "Some e-mail clients does not render SVG, also generating PNG."
|
||||
msgstr "Bazı e-posta istemcileri SVG göstermez, PNG de oluşturuluyor."
|
||||
|
||||
#: accounts/templates/accounts/email_otp_form.html:12
|
||||
msgid ""
|
||||
"\n"
|
||||
|
|
@ -408,6 +395,16 @@ msgstr "Doğrula ve oturum aç"
|
|||
msgid "2FA Protection Active"
|
||||
msgstr "2FA Koruması Etkin"
|
||||
|
||||
#: accounts/templates/accounts/otp_qr.html:4
|
||||
#: accounts/templates/accounts/otp_qr.html:6 accounts/utils.py:70
|
||||
msgid "OTP QR Code"
|
||||
msgstr "OTP QR Kodu"
|
||||
|
||||
#: accounts/templates/accounts/otp_qr.html:11
|
||||
#, python-format
|
||||
msgid "Scan this QR code to get OTP for account '%(user)s'"
|
||||
msgstr "'%(user)s' hesabının OTP kodlarını almak için bu QR kodunu tarayın"
|
||||
|
||||
#: accounts/templates/api_token_created.html:4
|
||||
#: accounts/templates/api_token_created.html:5
|
||||
msgid "API token created"
|
||||
|
|
@ -526,7 +523,7 @@ msgid "Add"
|
|||
msgstr "Ekle"
|
||||
|
||||
#: accounts/templates/profile.html:64 accounts/templates/profile.html:70
|
||||
#: accounts/views.py:69 accounts/views.py:73
|
||||
#: accounts/views.py:70 accounts/views.py:74
|
||||
msgid "Create an API token"
|
||||
msgstr "API anahtarı oluştur"
|
||||
|
||||
|
|
@ -556,97 +553,100 @@ msgstr "%(name)s SSH anahtarı için işlemler"
|
|||
msgid "No SSH keys."
|
||||
msgstr "SSH anahtarı yok."
|
||||
|
||||
#: accounts/utils.py:65
|
||||
msgid "OTP QR Code"
|
||||
msgstr "OTP QR Kodu"
|
||||
#: accounts/utils.py:71
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Open this link within an hour to see the OTP QR code of account '%(user)s':\n"
|
||||
"\n"
|
||||
"%(url)s"
|
||||
msgstr ""
|
||||
"'%(user)s' hesabının OTP QR kodunu görmek için bu bağlantıyı bir saat içinde açın:\n"
|
||||
"\n"
|
||||
"%(url)s"
|
||||
|
||||
#: accounts/utils.py:66
|
||||
msgid "Please view HTML version of this message."
|
||||
msgstr "Lütfen bu mesajın HTML sürümünü görüntüleyin."
|
||||
|
||||
#: accounts/views.py:46
|
||||
#: accounts/views.py:47
|
||||
msgid "Profile updated"
|
||||
msgstr "Profil güncellendi"
|
||||
|
||||
#: accounts/views.py:72
|
||||
#: accounts/views.py:73
|
||||
#, python-format
|
||||
msgid "A user has at most %(limit)s API tokens: delete one first."
|
||||
msgstr ""
|
||||
"Bir kullanıcının en fazla %(limit)s API anahtarı olabilir: önce birini silin."
|
||||
|
||||
#: accounts/views.py:107
|
||||
#: accounts/views.py:108
|
||||
msgid "API token deleted"
|
||||
msgstr "API anahtarı silindi"
|
||||
|
||||
#: accounts/views.py:115
|
||||
#: accounts/views.py:116
|
||||
msgid "Delete API token"
|
||||
msgstr "API anahtarını sil"
|
||||
|
||||
#: accounts/views.py:116
|
||||
#: accounts/views.py:117
|
||||
msgid "Scripts and tools that use it can no longer reach the API."
|
||||
msgstr "Onu kullanan betikler ve araçlar artık API'ye erişemez."
|
||||
|
||||
#: accounts/views.py:126
|
||||
#: accounts/views.py:127
|
||||
msgid "SSH key added"
|
||||
msgstr "SSH anahtarı eklendi"
|
||||
|
||||
#: accounts/views.py:134
|
||||
#: accounts/views.py:135
|
||||
msgid "Add SSH key"
|
||||
msgstr "SSH Anahtarı Ekle"
|
||||
|
||||
#: accounts/views.py:155
|
||||
#: accounts/views.py:156
|
||||
msgid "SSH key deleted"
|
||||
msgstr "SSH anahtarı silindi"
|
||||
|
||||
#: accounts/views.py:163
|
||||
#: accounts/views.py:164
|
||||
msgid "Delete SSH key"
|
||||
msgstr "SSH anahtarı sil"
|
||||
|
||||
#: accounts/views.py:164
|
||||
#: accounts/views.py:165
|
||||
msgid ""
|
||||
"VMs that already have it keep it; VMs created from now on do not get it."
|
||||
msgstr ""
|
||||
"Ona zaten sahip olan sanal makinelerde kalır; bundan sonra oluşturulan sanal "
|
||||
"makinelere eklenmez."
|
||||
|
||||
#: accounts/views.py:212
|
||||
#: accounts/views.py:213
|
||||
msgid "Password Changed"
|
||||
msgstr "Parola Değişti"
|
||||
|
||||
#: accounts/views.py:241
|
||||
#: accounts/views.py:242
|
||||
msgid "Create User Instance"
|
||||
msgstr "Yeni Kullanıcı Sanal Makinesi Oluştur"
|
||||
|
||||
#: accounts/views.py:275
|
||||
#: accounts/views.py:276
|
||||
msgid "Update User Instance"
|
||||
msgstr "Kullanıcı Sanal Makinesini Güncelle"
|
||||
|
||||
#: accounts/views.py:293
|
||||
#: accounts/views.py:294
|
||||
msgid "The user loses access to this VM; the VM itself is not changed."
|
||||
msgstr ""
|
||||
"Kullanıcı bu sanal makineye erişimini kaybeder; sanal makinenin kendisi "
|
||||
"değişmez."
|
||||
|
||||
#: accounts/views.py:326
|
||||
#: accounts/views.py:327
|
||||
#, python-format
|
||||
msgid "OTP Sent to %(email)s"
|
||||
msgstr "OTP %(email)s ye gönderildi"
|
||||
|
||||
#: accounts/views.py:335
|
||||
#: accounts/views.py:336
|
||||
msgid "Email OTP"
|
||||
msgstr "OTP E-Postası"
|
||||
|
||||
#: accounts/views.py:350
|
||||
#: accounts/views.py:370
|
||||
#, python-format
|
||||
msgid "The OTP QR code could not be mailed to %(user)s: %(error)s"
|
||||
msgstr "OTP QR kodu %(user)s kullanıcısına e-postayla gönderilemedi: %(error)s"
|
||||
|
||||
#: accounts/views.py:354
|
||||
#: accounts/views.py:374
|
||||
#, python-format
|
||||
msgid "OTP QR code was emailed to user %(user)s"
|
||||
msgstr "OTP QR kodu %(user)s kullanıcısına gönderildi"
|
||||
|
||||
#: accounts/views.py:358
|
||||
#: accounts/views.py:378
|
||||
msgid "User email not set, failed to send QR code"
|
||||
msgstr "Kullanıcı e-postası girilmemiş, QR kod gönderimi başarısız"
|
||||
|
||||
|
|
@ -2699,57 +2699,57 @@ msgstr ""
|
|||
msgid "The name of the virtual machine must not contain any special characters"
|
||||
msgstr "Sanal makine adı özel karakterler içeremez"
|
||||
|
||||
#: instances/jobs.py:68
|
||||
#: instances/jobs.py:69
|
||||
msgid "The VM moved to another compute after the job was queued"
|
||||
msgstr "İş kuyruğa alındıktan sonra sanal makine başka bir sunucuya taşındı"
|
||||
|
||||
#: instances/jobs.py:136
|
||||
#: instances/jobs.py:137
|
||||
msgid "Enter a whole number of vCPUs for the clone"
|
||||
msgstr "Klon için vCPU sayısını tam sayı olarak girin"
|
||||
|
||||
#: instances/jobs.py:138
|
||||
#: instances/jobs.py:139
|
||||
msgid "Enter a whole number of memory (MiB) for the clone"
|
||||
msgstr "Klon için belleği (MiB) tam sayı olarak girin"
|
||||
|
||||
#: instances/jobs.py:165
|
||||
#: instances/jobs.py:166
|
||||
msgid "Choose the clone's name from the list of free names"
|
||||
msgstr "Klonun adını boş adlar listesinden seçin"
|
||||
|
||||
#: instances/jobs.py:193
|
||||
#: instances/jobs.py:194
|
||||
#, python-format
|
||||
msgid "%(quota)s: cannot create '%(clone_name)s'."
|
||||
msgstr "%(quota)s: '%(clone_name)s' oluşturulamıyor."
|
||||
|
||||
#: instances/jobs.py:196 instances/jobs.py:545
|
||||
#: instances/jobs.py:197 instances/jobs.py:546
|
||||
#, python-format
|
||||
msgid "Instance '%(clone_name)s' already exists!"
|
||||
msgstr "'%(clone_name)s' sanal makinesi zaten mevcut!"
|
||||
|
||||
#: instances/jobs.py:198
|
||||
#: instances/jobs.py:199
|
||||
#, python-format
|
||||
msgid "Instance name '%(clone_name)s' contains invalid characters!"
|
||||
msgstr "Sanal makine adı '%(clone_name)s' geçersiz karakterler içermektedir!"
|
||||
|
||||
#: instances/jobs.py:201
|
||||
#: instances/jobs.py:202
|
||||
#, python-format
|
||||
msgid "Instance MAC '%(clone_mac)s' invalid format!"
|
||||
msgstr "Sanal makine MAC adresi '%(clone_mac)s' geçersiz biçemde belirtilmiş!"
|
||||
|
||||
#: instances/jobs.py:203
|
||||
#: instances/jobs.py:204
|
||||
#, python-format
|
||||
msgid "Disk name '%(disk_name)s' contains invalid characters!"
|
||||
msgstr "Disk adı '%(disk_name)s' geçersiz karakterler içeriyor!"
|
||||
|
||||
#: instances/jobs.py:223
|
||||
#: instances/jobs.py:224
|
||||
#, python-format
|
||||
msgid "The clone is made, but not as asked: %(error)s"
|
||||
msgstr "Klon oluşturuldu, ama istendiği gibi değil: %(error)s"
|
||||
|
||||
#: instances/jobs.py:279
|
||||
#: instances/jobs.py:280
|
||||
msgid "The VM was not defined and no volume of it was left."
|
||||
msgstr "Sanal makine tanımlanmadı ve ondan geriye birim kalmadı."
|
||||
|
||||
#: instances/jobs.py:281
|
||||
#: instances/jobs.py:282
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The VM was not defined; check these volumes, then resolve this job: "
|
||||
|
|
@ -2758,28 +2758,28 @@ msgstr ""
|
|||
"Sanal makine tanımlanmadı; şu birimleri kontrol edin, sonra bu işi çözün: "
|
||||
"%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:302
|
||||
#: instances/jobs.py:303
|
||||
msgid "Delete snapshot"
|
||||
msgstr "Anlık görüntüyü sil"
|
||||
|
||||
#: instances/jobs.py:312 vrtManager/instance.py:2286
|
||||
#: instances/jobs.py:313 vrtManager/instance.py:2286
|
||||
#, python-format
|
||||
msgid "The VM has no external snapshot named %(name)s"
|
||||
msgstr "Sanal makinede %(name)s adında harici anlık görüntü yok"
|
||||
|
||||
#: instances/jobs.py:325
|
||||
#: instances/jobs.py:326
|
||||
msgid "Merging the snapshot"
|
||||
msgstr "Anlık görüntü birleştiriliyor"
|
||||
|
||||
#: instances/jobs.py:344
|
||||
#: instances/jobs.py:345
|
||||
msgid "The VM is no longer on its compute"
|
||||
msgstr "Sanal makine artık kendi sunucusunda değil"
|
||||
|
||||
#: instances/jobs.py:347
|
||||
#: instances/jobs.py:348
|
||||
msgid "libvirt is still merging the snapshot"
|
||||
msgstr "libvirt anlık görüntüyü hâlâ birleştiriyor"
|
||||
|
||||
#: instances/jobs.py:355
|
||||
#: instances/jobs.py:356
|
||||
msgid ""
|
||||
"The snapshot is still listed and no merge runs. It cannot be told whether the"
|
||||
" disks changed; check the VM before deleting the snapshot again or reverting "
|
||||
|
|
@ -2789,44 +2789,44 @@ msgstr ""
|
|||
"değişmediği bilinemiyor; anlık görüntüyü yeniden silmeden ya da ona dönmeden "
|
||||
"önce sanal makineyi kontrol edin."
|
||||
|
||||
#: instances/jobs.py:362
|
||||
#: instances/jobs.py:363
|
||||
msgid "The VM is no longer on the compute the job ran on"
|
||||
msgstr "Sanal makine artık işin çalıştığı sunucuda değil"
|
||||
|
||||
#: instances/jobs.py:368
|
||||
#: instances/jobs.py:369
|
||||
msgid "Take snapshot"
|
||||
msgstr "Anlık görüntü al"
|
||||
|
||||
#: instances/jobs.py:380 vrtManager/instance.py:1882
|
||||
#: instances/jobs.py:381 vrtManager/instance.py:1882
|
||||
#, python-format
|
||||
msgid "A snapshot named %(name)s already exists"
|
||||
msgstr "%(name)s adında bir anlık görüntü zaten var"
|
||||
|
||||
#: instances/jobs.py:392
|
||||
#: instances/jobs.py:393
|
||||
msgid "Taking the snapshot"
|
||||
msgstr "Anlık görüntü alınıyor"
|
||||
|
||||
#: instances/jobs.py:405
|
||||
#: instances/jobs.py:406
|
||||
msgid "libvirt is still taking the snapshot"
|
||||
msgstr "libvirt anlık görüntüyü hâlâ alıyor"
|
||||
|
||||
#: instances/jobs.py:408
|
||||
#: instances/jobs.py:409
|
||||
msgid "The snapshot was not taken"
|
||||
msgstr "Anlık görüntü alınmadı"
|
||||
|
||||
#: instances/jobs.py:431
|
||||
#: instances/jobs.py:432
|
||||
msgid "Revert snapshot"
|
||||
msgstr "Snapshota dön"
|
||||
|
||||
#: instances/jobs.py:462
|
||||
#: instances/jobs.py:463
|
||||
msgid "Reverting the VM"
|
||||
msgstr "Sanal makine geri döndürülüyor"
|
||||
|
||||
#: instances/jobs.py:473
|
||||
#: instances/jobs.py:474
|
||||
msgid "libvirt is still reverting the VM"
|
||||
msgstr "libvirt sanal makineyi hâlâ geri döndürüyor"
|
||||
|
||||
#: instances/jobs.py:477
|
||||
#: instances/jobs.py:478
|
||||
msgid ""
|
||||
"It cannot be told whether the VM was reverted; check it, then revert again or"
|
||||
" resolve this job."
|
||||
|
|
@ -2834,12 +2834,12 @@ msgstr ""
|
|||
"Sanal makinenin geri döndürülüp döndürülmediği bilinemiyor; kontrol edin, "
|
||||
"sonra yeniden geri döndürün ya da bu işi çözün."
|
||||
|
||||
#: instances/jobs.py:494
|
||||
#: instances/jobs.py:495
|
||||
msgid "The VM must be running, paused or shut off to be cloned"
|
||||
msgstr ""
|
||||
"Klonlanmak için sanal makine çalışıyor, duraklatılmış ya da kapalı olmalı"
|
||||
|
||||
#: instances/jobs.py:495
|
||||
#: instances/jobs.py:496
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The guest's file systems could not be thawed (%(error)s); thaw them with: "
|
||||
|
|
@ -2848,19 +2848,19 @@ msgstr ""
|
|||
"Konuğun dosya sistemleri çözülemedi (%(error)s); şununla çözün: virsh "
|
||||
"domfsthaw %(vm)s"
|
||||
|
||||
#: instances/jobs.py:514
|
||||
#: instances/jobs.py:515
|
||||
msgid "Clone VM"
|
||||
msgstr "Sanal makineyi klonla"
|
||||
|
||||
#: instances/jobs.py:555 instances/jobs.py:562 instances/jobs.py:830
|
||||
#: instances/jobs.py:556 instances/jobs.py:563 instances/jobs.py:831
|
||||
msgid "Copying disks"
|
||||
msgstr "Diskler kopyalanıyor"
|
||||
|
||||
#: instances/jobs.py:614
|
||||
#: instances/jobs.py:615
|
||||
msgid "libvirt has no result of the copy"
|
||||
msgstr "libvirt'te kopyanın sonucu yok"
|
||||
|
||||
#: instances/jobs.py:625
|
||||
#: instances/jobs.py:626
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The copy failed: %(error)s. The guest's file systems could not be thawed "
|
||||
|
|
@ -2869,11 +2869,11 @@ msgstr ""
|
|||
"Kopyalama başarısız oldu: %(error)s. Konuğun dosya sistemleri çözülemedi "
|
||||
"(%(thaw)s); şununla çözün: virsh domfsthaw %(vm)s"
|
||||
|
||||
#: instances/jobs.py:628 instances/jobs.py:712
|
||||
#: instances/jobs.py:629 instances/jobs.py:713
|
||||
msgid "The copy failed"
|
||||
msgstr "Kopyalama başarısız oldu"
|
||||
|
||||
#: instances/jobs.py:653
|
||||
#: instances/jobs.py:654
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The VM is no longer on the compute the job ran on; check these volumes, then "
|
||||
|
|
@ -2882,7 +2882,7 @@ msgstr ""
|
|||
"Sanal makine artık işin çalıştığı sunucuda değil; şu birimleri kontrol edin, "
|
||||
"sonra bu işi çözün: %(volumes)s"
|
||||
|
||||
#: instances/jobs.py:688
|
||||
#: instances/jobs.py:689
|
||||
#, python-format
|
||||
msgid ""
|
||||
"A volume of the clone was being created; check these volumes, then resolve "
|
||||
|
|
@ -2891,15 +2891,15 @@ msgstr ""
|
|||
"Klonun bir birimi oluşturuluyordu; şu birimleri kontrol edin, sonra bu işi "
|
||||
"çözün: %(volumes)s"
|
||||
|
||||
#: instances/jobs.py:692
|
||||
#: instances/jobs.py:693
|
||||
msgid "The job stopped before the copy started"
|
||||
msgstr "İş, kopyalama başlamadan durdu"
|
||||
|
||||
#: instances/jobs.py:699
|
||||
#: instances/jobs.py:700
|
||||
msgid "libvirt is still copying the disks"
|
||||
msgstr "libvirt diskleri hâlâ kopyalıyor"
|
||||
|
||||
#: instances/jobs.py:702
|
||||
#: instances/jobs.py:703
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Another backup runs on the VM; check these volumes, then resolve this job: "
|
||||
|
|
@ -2908,7 +2908,7 @@ msgstr ""
|
|||
"Sanal makinede başka bir yedekleme çalışıyor; şu birimleri kontrol edin, "
|
||||
"sonra bu işi çözün: %(volumes)s"
|
||||
|
||||
#: instances/jobs.py:707
|
||||
#: instances/jobs.py:708
|
||||
#, python-format
|
||||
msgid ""
|
||||
"libvirt has no result of the copy; check these volumes, then resolve this "
|
||||
|
|
@ -2917,140 +2917,140 @@ msgstr ""
|
|||
"libvirt'te kopyanın sonucu yok; şu birimleri kontrol edin, sonra bu işi "
|
||||
"çözün: %(volumes)s"
|
||||
|
||||
#: instances/jobs.py:735 instances/templates/create_instance_w1.html:60
|
||||
#: instances/jobs.py:736 instances/templates/create_instance_w1.html:60
|
||||
#: instances/templates/create_instance_w2.html:356
|
||||
msgid "Create VM"
|
||||
msgstr "Sanal makine oluştur"
|
||||
|
||||
#: instances/jobs.py:759 instances/jobs.py:804 instances/views.py:2491
|
||||
#: instances/jobs.py:760 instances/jobs.py:805 instances/views.py:2491
|
||||
msgid "A virtual machine with this name already exists"
|
||||
msgstr "Bu isimde bir sanal makine mevcut"
|
||||
|
||||
#: instances/jobs.py:761 instances/jobs.py:801
|
||||
#: instances/jobs.py:762 instances/jobs.py:802
|
||||
msgid "There is an instance with same name. Remove it and try again!"
|
||||
msgstr "Aynı isimde başka bir sanal makine var. Onu silip yeniden deneyin!"
|
||||
|
||||
#: instances/jobs.py:764
|
||||
#: instances/jobs.py:765
|
||||
msgid "First you need to create or select an image"
|
||||
msgstr "Öncelikle bir imaj oluşturun ya da mevcut bir imajı seçin"
|
||||
|
||||
#: instances/jobs.py:766
|
||||
#: instances/jobs.py:767
|
||||
msgid "Choose one disk source: a new disk, existing volumes or a template"
|
||||
msgstr ""
|
||||
"Tek bir disk kaynağı seçin: yeni bir disk, mevcut birimler ya da bir şablon"
|
||||
|
||||
#: instances/jobs.py:768
|
||||
#: instances/jobs.py:769
|
||||
msgid "Image has already exist. Please check volumes or change instance name"
|
||||
msgstr ""
|
||||
"İmaj zaten mevcut. Lütfen ya makine adını değişin ya da disk alanlarını "
|
||||
"kontrol edin"
|
||||
|
||||
#: instances/jobs.py:770
|
||||
#: instances/jobs.py:771
|
||||
msgid "Invalid cache mode"
|
||||
msgstr "Geçersiz önbellek modu"
|
||||
|
||||
#: instances/jobs.py:772
|
||||
#: instances/jobs.py:773
|
||||
#, python-format
|
||||
msgid "This host offers no CPU mode or model %(mode)s"
|
||||
msgstr "Bu sunucu %(mode)s CPU modunu veya modelini sunmuyor"
|
||||
|
||||
#: instances/jobs.py:774
|
||||
#: instances/jobs.py:775
|
||||
#, python-format
|
||||
msgid "A CD-ROM cannot use the %(bus)s bus"
|
||||
msgstr "CD-ROM %(bus)s veri yolunu kullanamaz"
|
||||
|
||||
#: instances/jobs.py:777
|
||||
#: instances/jobs.py:778
|
||||
msgid "The cloud-init seed is read from a CD-ROM: choose its bus"
|
||||
msgstr "cloud-init tohumu bir CD-ROM'dan okunur: veri yolunu seçin"
|
||||
|
||||
#: instances/jobs.py:780
|
||||
#: instances/jobs.py:781
|
||||
msgid "The cloud-init seed needs a directory storage pool for the disk"
|
||||
msgstr "cloud-init tohumu, disk için bir dizin depolama havuzu gerektirir"
|
||||
|
||||
#: instances/jobs.py:786
|
||||
#: instances/jobs.py:787
|
||||
#, python-format
|
||||
msgid "This host offers no firmware %(firmware)s"
|
||||
msgstr "Bu sunucu %(firmware)s firmware'ini sunmuyor"
|
||||
|
||||
#: instances/jobs.py:788 vrtManager/instance.py:3010
|
||||
#: instances/jobs.py:789 vrtManager/instance.py:3010
|
||||
msgid "This host offers no TPM 2.0: install swtpm on it"
|
||||
msgstr "Bu sunucu TPM 2.0 sunmuyor: üzerine swtpm kurun"
|
||||
|
||||
#: instances/jobs.py:849
|
||||
#: instances/jobs.py:850
|
||||
msgid "Writing the cloud-init seed"
|
||||
msgstr "cloud-init tohumu yazılıyor"
|
||||
|
||||
#: instances/jobs.py:856
|
||||
#: instances/jobs.py:857
|
||||
msgid "Defining the VM"
|
||||
msgstr "Sanal makine tanımlanıyor"
|
||||
|
||||
#: instances/jobs.py:889
|
||||
#: instances/jobs.py:890
|
||||
#, python-format
|
||||
msgid "%(error)s. Remove these volumes by hand: %(volumes)s"
|
||||
msgstr "%(error)s. Şu birimleri elle silin: %(volumes)s"
|
||||
|
||||
#: instances/jobs.py:914
|
||||
#: instances/jobs.py:915
|
||||
#, python-format
|
||||
msgid "The instance is already on %(compute)s"
|
||||
msgstr "Sanal makine zaten %(compute)s üzerinde"
|
||||
|
||||
#: instances/jobs.py:916
|
||||
#: instances/jobs.py:917
|
||||
#, python-format
|
||||
msgid "%(compute)s is not reachable"
|
||||
msgstr "%(compute)s sunucusuna ulaşılamıyor"
|
||||
|
||||
#: instances/jobs.py:918
|
||||
#: instances/jobs.py:919
|
||||
#, python-format
|
||||
msgid "%(compute)s is the VM's own host under another name"
|
||||
msgstr "%(compute)s, sanal makinenin kendi sunucusunun başka bir adı"
|
||||
|
||||
#: instances/jobs.py:936
|
||||
#: instances/jobs.py:937
|
||||
msgid "Migrate VM"
|
||||
msgstr "Sanal makineyi taşı"
|
||||
|
||||
#: instances/jobs.py:980
|
||||
#: instances/jobs.py:981
|
||||
msgid "Migrating the VM"
|
||||
msgstr "Sanal makine taşınıyor"
|
||||
|
||||
#: instances/jobs.py:1027
|
||||
#: instances/jobs.py:1028
|
||||
msgid "libvirt is still migrating the VM"
|
||||
msgstr "libvirt sanal makineyi hâlâ taşıyor"
|
||||
|
||||
#: instances/jobs.py:1034
|
||||
#: instances/jobs.py:1035
|
||||
msgid "The VM stayed on the source host"
|
||||
msgstr "Sanal makine kaynak sunucuda kaldı"
|
||||
|
||||
#: instances/jobs.py:1036
|
||||
#: instances/jobs.py:1037
|
||||
msgid ""
|
||||
"The VM is on both computes or on neither; check them, then resolve this job."
|
||||
msgstr ""
|
||||
"Sanal makine ya iki sunucuda da var ya da hiçbirinde yok; kontrol edin, sonra"
|
||||
" bu işi çözün."
|
||||
|
||||
#: instances/jobs.py:1068
|
||||
#: instances/jobs.py:1085
|
||||
msgid "Move disk"
|
||||
msgstr "Diski taşı"
|
||||
|
||||
#: instances/jobs.py:1075
|
||||
#: instances/jobs.py:1092
|
||||
msgid "Choose a disk of the VM"
|
||||
msgstr "Sanal makinenin bir diskini seçin"
|
||||
|
||||
#: instances/jobs.py:1099
|
||||
#: instances/jobs.py:1116
|
||||
msgid "Copying the disk"
|
||||
msgstr "Disk kopyalanıyor"
|
||||
|
||||
#: instances/jobs.py:1142
|
||||
#: instances/jobs.py:1159
|
||||
#, python-format
|
||||
msgid "Another block job runs on %(dev)s; check the VM"
|
||||
msgstr ""
|
||||
"%(dev)s üzerinde başka bir blok işi çalışıyor; sanal makineyi kontrol edin"
|
||||
|
||||
#: instances/jobs.py:1148
|
||||
#: instances/jobs.py:1165
|
||||
#, python-format
|
||||
msgid "The disk stayed in %(path)s; remove %(dest)s if it is there"
|
||||
msgstr "Disk %(path)s içinde kaldı; varsa %(dest)s dosyasını silin"
|
||||
|
||||
#: instances/jobs.py:1150
|
||||
#: instances/jobs.py:1167
|
||||
#, python-format
|
||||
msgid "The definitions of %(dev)s name %(paths)s; check the VM"
|
||||
msgstr "%(dev)s tanımları %(paths)s gösteriyor; sanal makineyi kontrol edin"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-11-05 13:37+0000\n"
|
||||
"POT-Creation-Date: 2026-11-05 14:27+0000\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
|
|
@ -311,19 +311,6 @@ msgstr "取消"
|
|||
msgid "Change password"
|
||||
msgstr "修改密码"
|
||||
|
||||
#: accounts/templates/accounts/email/otp.html:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"Scan this QR code to get OTP for account '%(user)s'\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"扫描此二维码获取账户“%(user)s”的 OTP\n"
|
||||
|
||||
#: accounts/templates/accounts/email/otp.html:7
|
||||
msgid "Some e-mail clients does not render SVG, also generating PNG."
|
||||
msgstr "部分邮件客户端无法显示 SVG,因此同时生成了 PNG。"
|
||||
|
||||
#: accounts/templates/accounts/email_otp_form.html:12
|
||||
msgid ""
|
||||
"\n"
|
||||
|
|
@ -405,6 +392,16 @@ msgstr "验证并登录"
|
|||
msgid "2FA Protection Active"
|
||||
msgstr "双因素保护已启用"
|
||||
|
||||
#: accounts/templates/accounts/otp_qr.html:4
|
||||
#: accounts/templates/accounts/otp_qr.html:6 accounts/utils.py:70
|
||||
msgid "OTP QR Code"
|
||||
msgstr "OTP 二维码"
|
||||
|
||||
#: accounts/templates/accounts/otp_qr.html:11
|
||||
#, python-format
|
||||
msgid "Scan this QR code to get OTP for account '%(user)s'"
|
||||
msgstr "扫描此二维码以获取账户“%(user)s”的 OTP"
|
||||
|
||||
#: accounts/templates/api_token_created.html:4
|
||||
#: accounts/templates/api_token_created.html:5
|
||||
msgid "API token created"
|
||||
|
|
@ -520,7 +517,7 @@ msgid "Add"
|
|||
msgstr "添加"
|
||||
|
||||
#: accounts/templates/profile.html:64 accounts/templates/profile.html:70
|
||||
#: accounts/views.py:69 accounts/views.py:73
|
||||
#: accounts/views.py:70 accounts/views.py:74
|
||||
msgid "Create an API token"
|
||||
msgstr "创建 API 令牌"
|
||||
|
||||
|
|
@ -548,92 +545,95 @@ msgstr "SSH 密钥 %(name)s 的操作"
|
|||
msgid "No SSH keys."
|
||||
msgstr "没有 SSH 密钥。"
|
||||
|
||||
#: accounts/utils.py:65
|
||||
msgid "OTP QR Code"
|
||||
msgstr "OTP 二维码"
|
||||
#: accounts/utils.py:71
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Open this link within an hour to see the OTP QR code of account '%(user)s':\n"
|
||||
"\n"
|
||||
"%(url)s"
|
||||
msgstr ""
|
||||
"请在一小时内打开此链接,查看账户“%(user)s”的 OTP 二维码:\n"
|
||||
"\n"
|
||||
"%(url)s"
|
||||
|
||||
#: accounts/utils.py:66
|
||||
msgid "Please view HTML version of this message."
|
||||
msgstr "请查看此邮件的 HTML 版本。"
|
||||
|
||||
#: accounts/views.py:46
|
||||
#: accounts/views.py:47
|
||||
msgid "Profile updated"
|
||||
msgstr "资料已更新"
|
||||
|
||||
#: accounts/views.py:72
|
||||
#: accounts/views.py:73
|
||||
#, python-format
|
||||
msgid "A user has at most %(limit)s API tokens: delete one first."
|
||||
msgstr "每个用户最多有 %(limit)s 个 API 令牌:请先删除一个。"
|
||||
|
||||
#: accounts/views.py:107
|
||||
#: accounts/views.py:108
|
||||
msgid "API token deleted"
|
||||
msgstr "API 令牌已删除"
|
||||
|
||||
#: accounts/views.py:115
|
||||
#: accounts/views.py:116
|
||||
msgid "Delete API token"
|
||||
msgstr "删除 API 令牌"
|
||||
|
||||
#: accounts/views.py:116
|
||||
#: accounts/views.py:117
|
||||
msgid "Scripts and tools that use it can no longer reach the API."
|
||||
msgstr "使用它的脚本和工具将无法再访问 API。"
|
||||
|
||||
#: accounts/views.py:126
|
||||
#: accounts/views.py:127
|
||||
msgid "SSH key added"
|
||||
msgstr "SSH 密钥已添加"
|
||||
|
||||
#: accounts/views.py:134
|
||||
#: accounts/views.py:135
|
||||
msgid "Add SSH key"
|
||||
msgstr "添加 SSH 密钥"
|
||||
|
||||
#: accounts/views.py:155
|
||||
#: accounts/views.py:156
|
||||
msgid "SSH key deleted"
|
||||
msgstr "SSH 密钥已删除"
|
||||
|
||||
#: accounts/views.py:163
|
||||
#: accounts/views.py:164
|
||||
msgid "Delete SSH key"
|
||||
msgstr "删除 SSH 密钥"
|
||||
|
||||
#: accounts/views.py:164
|
||||
#: accounts/views.py:165
|
||||
msgid ""
|
||||
"VMs that already have it keep it; VMs created from now on do not get it."
|
||||
msgstr "已有此密钥的虚拟机会保留它;此后创建的虚拟机不会再获得它。"
|
||||
|
||||
#: accounts/views.py:212
|
||||
#: accounts/views.py:213
|
||||
msgid "Password Changed"
|
||||
msgstr "密码已修改"
|
||||
|
||||
#: accounts/views.py:241
|
||||
#: accounts/views.py:242
|
||||
msgid "Create User Instance"
|
||||
msgstr "创建用户虚拟机"
|
||||
|
||||
#: accounts/views.py:275
|
||||
#: accounts/views.py:276
|
||||
msgid "Update User Instance"
|
||||
msgstr "更新用户虚拟机"
|
||||
|
||||
#: accounts/views.py:293
|
||||
#: accounts/views.py:294
|
||||
msgid "The user loses access to this VM; the VM itself is not changed."
|
||||
msgstr "该用户将失去对此虚拟机的访问权限;虚拟机本身不受影响。"
|
||||
|
||||
#: accounts/views.py:326
|
||||
#: accounts/views.py:327
|
||||
#, python-format
|
||||
msgid "OTP Sent to %(email)s"
|
||||
msgstr "OTP 已发送至 %(email)s"
|
||||
|
||||
#: accounts/views.py:335
|
||||
#: accounts/views.py:336
|
||||
msgid "Email OTP"
|
||||
msgstr "邮件发送 OTP"
|
||||
|
||||
#: accounts/views.py:350
|
||||
#: accounts/views.py:370
|
||||
#, python-format
|
||||
msgid "The OTP QR code could not be mailed to %(user)s: %(error)s"
|
||||
msgstr "无法将 OTP 二维码通过邮件发送给 %(user)s:%(error)s"
|
||||
|
||||
#: accounts/views.py:354
|
||||
#: accounts/views.py:374
|
||||
#, python-format
|
||||
msgid "OTP QR code was emailed to user %(user)s"
|
||||
msgstr "OTP 二维码已通过邮件发送给用户 %(user)s"
|
||||
|
||||
#: accounts/views.py:358
|
||||
#: accounts/views.py:378
|
||||
msgid "User email not set, failed to send QR code"
|
||||
msgstr "未设置用户邮箱,无法发送二维码"
|
||||
|
||||
|
|
@ -2604,332 +2604,332 @@ msgstr "控制台监听的不只是 localhost;只有管理员可以移除其
|
|||
msgid "The name of the virtual machine must not contain any special characters"
|
||||
msgstr "虚拟机名称不能包含任何特殊字符"
|
||||
|
||||
#: instances/jobs.py:68
|
||||
#: instances/jobs.py:69
|
||||
msgid "The VM moved to another compute after the job was queued"
|
||||
msgstr "任务排队后,虚拟机已移到另一个计算节点"
|
||||
|
||||
#: instances/jobs.py:136
|
||||
#: instances/jobs.py:137
|
||||
msgid "Enter a whole number of vCPUs for the clone"
|
||||
msgstr "请为克隆输入整数个 vCPU"
|
||||
|
||||
#: instances/jobs.py:138
|
||||
#: instances/jobs.py:139
|
||||
msgid "Enter a whole number of memory (MiB) for the clone"
|
||||
msgstr "请为克隆输入整数的内存(MiB)"
|
||||
|
||||
#: instances/jobs.py:165
|
||||
#: instances/jobs.py:166
|
||||
msgid "Choose the clone's name from the list of free names"
|
||||
msgstr "请从可用名称列表中选择克隆的名称"
|
||||
|
||||
#: instances/jobs.py:193
|
||||
#: instances/jobs.py:194
|
||||
#, python-format
|
||||
msgid "%(quota)s: cannot create '%(clone_name)s'."
|
||||
msgstr "%(quota)s:无法创建“%(clone_name)s”。"
|
||||
|
||||
#: instances/jobs.py:196 instances/jobs.py:545
|
||||
#: instances/jobs.py:197 instances/jobs.py:546
|
||||
#, python-format
|
||||
msgid "Instance '%(clone_name)s' already exists!"
|
||||
msgstr "虚拟机 '%(clone_name)s' 已存在!"
|
||||
|
||||
#: instances/jobs.py:198
|
||||
#: instances/jobs.py:199
|
||||
#, python-format
|
||||
msgid "Instance name '%(clone_name)s' contains invalid characters!"
|
||||
msgstr "虚拟机名称 '%(clone_name)s' 包含无效字符!"
|
||||
|
||||
#: instances/jobs.py:201
|
||||
#: instances/jobs.py:202
|
||||
#, python-format
|
||||
msgid "Instance MAC '%(clone_mac)s' invalid format!"
|
||||
msgstr "虚拟机 MAC '%(clone_mac)s' 格式无效!"
|
||||
|
||||
#: instances/jobs.py:203
|
||||
#: instances/jobs.py:204
|
||||
#, python-format
|
||||
msgid "Disk name '%(disk_name)s' contains invalid characters!"
|
||||
msgstr "磁盘名称 '%(disk_name)s' 包含无效字符!"
|
||||
|
||||
#: instances/jobs.py:223
|
||||
#: instances/jobs.py:224
|
||||
#, python-format
|
||||
msgid "The clone is made, but not as asked: %(error)s"
|
||||
msgstr "克隆已创建,但与要求不符:%(error)s"
|
||||
|
||||
#: instances/jobs.py:279
|
||||
#: instances/jobs.py:280
|
||||
msgid "The VM was not defined and no volume of it was left."
|
||||
msgstr "虚拟机未定义,也没有留下它的卷。"
|
||||
|
||||
#: instances/jobs.py:281
|
||||
#: instances/jobs.py:282
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The VM was not defined; check these volumes, then resolve this job: "
|
||||
"%(volumes)s"
|
||||
msgstr "虚拟机未定义;请检查这些卷,然后处理此任务:%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:302
|
||||
#: instances/jobs.py:303
|
||||
msgid "Delete snapshot"
|
||||
msgstr "删除快照"
|
||||
|
||||
#: instances/jobs.py:312 vrtManager/instance.py:2286
|
||||
#: instances/jobs.py:313 vrtManager/instance.py:2286
|
||||
#, python-format
|
||||
msgid "The VM has no external snapshot named %(name)s"
|
||||
msgstr "虚拟机没有名为 %(name)s 的外部快照"
|
||||
|
||||
#: instances/jobs.py:325
|
||||
#: instances/jobs.py:326
|
||||
msgid "Merging the snapshot"
|
||||
msgstr "正在合并快照"
|
||||
|
||||
#: instances/jobs.py:344
|
||||
#: instances/jobs.py:345
|
||||
msgid "The VM is no longer on its compute"
|
||||
msgstr "虚拟机已不在其计算节点上"
|
||||
|
||||
#: instances/jobs.py:347
|
||||
#: instances/jobs.py:348
|
||||
msgid "libvirt is still merging the snapshot"
|
||||
msgstr "libvirt 仍在合并快照"
|
||||
|
||||
#: instances/jobs.py:355
|
||||
#: instances/jobs.py:356
|
||||
msgid ""
|
||||
"The snapshot is still listed and no merge runs. It cannot be told whether the"
|
||||
" disks changed; check the VM before deleting the snapshot again or reverting "
|
||||
"to it."
|
||||
msgstr "快照仍在列表中,且没有正在运行的合并。无法判断磁盘是否已更改;再次删除该快照或恢复到它之前,请检查虚拟机。"
|
||||
|
||||
#: instances/jobs.py:362
|
||||
#: instances/jobs.py:363
|
||||
msgid "The VM is no longer on the compute the job ran on"
|
||||
msgstr "虚拟机已不在运行该任务的计算节点上"
|
||||
|
||||
#: instances/jobs.py:368
|
||||
#: instances/jobs.py:369
|
||||
msgid "Take snapshot"
|
||||
msgstr "创建快照"
|
||||
|
||||
#: instances/jobs.py:380 vrtManager/instance.py:1882
|
||||
#: instances/jobs.py:381 vrtManager/instance.py:1882
|
||||
#, python-format
|
||||
msgid "A snapshot named %(name)s already exists"
|
||||
msgstr "名为 %(name)s 的快照已存在"
|
||||
|
||||
#: instances/jobs.py:392
|
||||
#: instances/jobs.py:393
|
||||
msgid "Taking the snapshot"
|
||||
msgstr "正在创建快照"
|
||||
|
||||
#: instances/jobs.py:405
|
||||
#: instances/jobs.py:406
|
||||
msgid "libvirt is still taking the snapshot"
|
||||
msgstr "libvirt 仍在创建快照"
|
||||
|
||||
#: instances/jobs.py:408
|
||||
#: instances/jobs.py:409
|
||||
msgid "The snapshot was not taken"
|
||||
msgstr "未创建快照"
|
||||
|
||||
#: instances/jobs.py:431
|
||||
#: instances/jobs.py:432
|
||||
msgid "Revert snapshot"
|
||||
msgstr "恢复快照"
|
||||
|
||||
#: instances/jobs.py:462
|
||||
#: instances/jobs.py:463
|
||||
msgid "Reverting the VM"
|
||||
msgstr "正在恢复虚拟机"
|
||||
|
||||
#: instances/jobs.py:473
|
||||
#: instances/jobs.py:474
|
||||
msgid "libvirt is still reverting the VM"
|
||||
msgstr "libvirt 仍在恢复虚拟机"
|
||||
|
||||
#: instances/jobs.py:477
|
||||
#: instances/jobs.py:478
|
||||
msgid ""
|
||||
"It cannot be told whether the VM was reverted; check it, then revert again or"
|
||||
" resolve this job."
|
||||
msgstr "无法判断虚拟机是否已恢复;请检查后再次恢复,或处理此任务。"
|
||||
|
||||
#: instances/jobs.py:494
|
||||
#: instances/jobs.py:495
|
||||
msgid "The VM must be running, paused or shut off to be cloned"
|
||||
msgstr "虚拟机必须处于运行、暂停或关机状态才能克隆"
|
||||
|
||||
#: instances/jobs.py:495
|
||||
#: instances/jobs.py:496
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The guest's file systems could not be thawed (%(error)s); thaw them with: "
|
||||
"virsh domfsthaw %(vm)s"
|
||||
msgstr "无法解冻客户机的文件系统(%(error)s);请用以下命令解冻:virsh domfsthaw %(vm)s"
|
||||
|
||||
#: instances/jobs.py:514
|
||||
#: instances/jobs.py:515
|
||||
msgid "Clone VM"
|
||||
msgstr "克隆虚拟机"
|
||||
|
||||
#: instances/jobs.py:555 instances/jobs.py:562 instances/jobs.py:830
|
||||
#: instances/jobs.py:556 instances/jobs.py:563 instances/jobs.py:831
|
||||
msgid "Copying disks"
|
||||
msgstr "正在复制磁盘"
|
||||
|
||||
#: instances/jobs.py:614
|
||||
#: instances/jobs.py:615
|
||||
msgid "libvirt has no result of the copy"
|
||||
msgstr "libvirt 没有复制的结果"
|
||||
|
||||
#: instances/jobs.py:625
|
||||
#: instances/jobs.py:626
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The copy failed: %(error)s. The guest's file systems could not be thawed "
|
||||
"(%(thaw)s); thaw them with: virsh domfsthaw %(vm)s"
|
||||
msgstr "复制失败:%(error)s。无法解冻客户机的文件系统(%(thaw)s);请用以下命令解冻:virsh domfsthaw %(vm)s"
|
||||
|
||||
#: instances/jobs.py:628 instances/jobs.py:712
|
||||
#: instances/jobs.py:629 instances/jobs.py:713
|
||||
msgid "The copy failed"
|
||||
msgstr "复制失败"
|
||||
|
||||
#: instances/jobs.py:653
|
||||
#: instances/jobs.py:654
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The VM is no longer on the compute the job ran on; check these volumes, then "
|
||||
"resolve this job: %(volumes)s"
|
||||
msgstr "虚拟机已不在运行该任务的计算节点上;请检查这些卷,然后处理此任务:%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:688
|
||||
#: instances/jobs.py:689
|
||||
#, python-format
|
||||
msgid ""
|
||||
"A volume of the clone was being created; check these volumes, then resolve "
|
||||
"this job: %(volumes)s"
|
||||
msgstr "克隆的一个卷正在创建中;请检查这些卷,然后处理此任务:%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:692
|
||||
#: instances/jobs.py:693
|
||||
msgid "The job stopped before the copy started"
|
||||
msgstr "任务在复制开始前停止"
|
||||
|
||||
#: instances/jobs.py:699
|
||||
#: instances/jobs.py:700
|
||||
msgid "libvirt is still copying the disks"
|
||||
msgstr "libvirt 仍在复制磁盘"
|
||||
|
||||
#: instances/jobs.py:702
|
||||
#: instances/jobs.py:703
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Another backup runs on the VM; check these volumes, then resolve this job: "
|
||||
"%(volumes)s"
|
||||
msgstr "虚拟机上正在运行另一个备份;请检查这些卷,然后处理此任务:%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:707
|
||||
#: instances/jobs.py:708
|
||||
#, python-format
|
||||
msgid ""
|
||||
"libvirt has no result of the copy; check these volumes, then resolve this "
|
||||
"job: %(volumes)s"
|
||||
msgstr "libvirt 没有复制的结果;请检查这些卷,然后处理此任务:%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:735 instances/templates/create_instance_w1.html:60
|
||||
#: instances/jobs.py:736 instances/templates/create_instance_w1.html:60
|
||||
#: instances/templates/create_instance_w2.html:356
|
||||
msgid "Create VM"
|
||||
msgstr "创建虚拟机"
|
||||
|
||||
#: instances/jobs.py:759 instances/jobs.py:804 instances/views.py:2491
|
||||
#: instances/jobs.py:760 instances/jobs.py:805 instances/views.py:2491
|
||||
msgid "A virtual machine with this name already exists"
|
||||
msgstr "同名虚拟机已存在"
|
||||
|
||||
#: instances/jobs.py:761 instances/jobs.py:801
|
||||
#: instances/jobs.py:762 instances/jobs.py:802
|
||||
msgid "There is an instance with same name. Remove it and try again!"
|
||||
msgstr "已存在同名虚拟机。请删除后重试!"
|
||||
|
||||
#: instances/jobs.py:764
|
||||
#: instances/jobs.py:765
|
||||
msgid "First you need to create or select an image"
|
||||
msgstr "请先创建或选择一个镜像"
|
||||
|
||||
#: instances/jobs.py:766
|
||||
#: instances/jobs.py:767
|
||||
msgid "Choose one disk source: a new disk, existing volumes or a template"
|
||||
msgstr "请选择一种磁盘来源:新磁盘、现有卷或模板"
|
||||
|
||||
#: instances/jobs.py:768
|
||||
#: instances/jobs.py:769
|
||||
msgid "Image has already exist. Please check volumes or change instance name"
|
||||
msgstr "镜像已存在。请检查卷或更改虚拟机名称"
|
||||
|
||||
#: instances/jobs.py:770
|
||||
#: instances/jobs.py:771
|
||||
msgid "Invalid cache mode"
|
||||
msgstr "无效的缓存模式"
|
||||
|
||||
#: instances/jobs.py:772
|
||||
#: instances/jobs.py:773
|
||||
#, python-format
|
||||
msgid "This host offers no CPU mode or model %(mode)s"
|
||||
msgstr "此主机不提供 CPU 模式或型号 %(mode)s"
|
||||
|
||||
#: instances/jobs.py:774
|
||||
#: instances/jobs.py:775
|
||||
#, python-format
|
||||
msgid "A CD-ROM cannot use the %(bus)s bus"
|
||||
msgstr "CD-ROM 不能使用 %(bus)s 总线"
|
||||
|
||||
#: instances/jobs.py:777
|
||||
#: instances/jobs.py:778
|
||||
msgid "The cloud-init seed is read from a CD-ROM: choose its bus"
|
||||
msgstr "cloud-init 种子从 CD-ROM 读取:请选择其总线"
|
||||
|
||||
#: instances/jobs.py:780
|
||||
#: instances/jobs.py:781
|
||||
msgid "The cloud-init seed needs a directory storage pool for the disk"
|
||||
msgstr "cloud-init 种子需要为磁盘使用目录类型的存储池"
|
||||
|
||||
#: instances/jobs.py:786
|
||||
#: instances/jobs.py:787
|
||||
#, python-format
|
||||
msgid "This host offers no firmware %(firmware)s"
|
||||
msgstr "此主机不提供固件 %(firmware)s"
|
||||
|
||||
#: instances/jobs.py:788 vrtManager/instance.py:3010
|
||||
#: instances/jobs.py:789 vrtManager/instance.py:3010
|
||||
msgid "This host offers no TPM 2.0: install swtpm on it"
|
||||
msgstr "此主机不提供 TPM 2.0:请在主机上安装 swtpm"
|
||||
|
||||
#: instances/jobs.py:849
|
||||
#: instances/jobs.py:850
|
||||
msgid "Writing the cloud-init seed"
|
||||
msgstr "正在写入 cloud-init 种子"
|
||||
|
||||
#: instances/jobs.py:856
|
||||
#: instances/jobs.py:857
|
||||
msgid "Defining the VM"
|
||||
msgstr "正在定义虚拟机"
|
||||
|
||||
#: instances/jobs.py:889
|
||||
#: instances/jobs.py:890
|
||||
#, python-format
|
||||
msgid "%(error)s. Remove these volumes by hand: %(volumes)s"
|
||||
msgstr "%(error)s。请手动删除这些卷:%(volumes)s"
|
||||
|
||||
#: instances/jobs.py:914
|
||||
#: instances/jobs.py:915
|
||||
#, python-format
|
||||
msgid "The instance is already on %(compute)s"
|
||||
msgstr "虚拟机已在 %(compute)s 上"
|
||||
|
||||
#: instances/jobs.py:916
|
||||
#: instances/jobs.py:917
|
||||
#, python-format
|
||||
msgid "%(compute)s is not reachable"
|
||||
msgstr "无法连接 %(compute)s"
|
||||
|
||||
#: instances/jobs.py:918
|
||||
#: instances/jobs.py:919
|
||||
#, python-format
|
||||
msgid "%(compute)s is the VM's own host under another name"
|
||||
msgstr "%(compute)s 是该虚拟机所在主机的另一个名称"
|
||||
|
||||
#: instances/jobs.py:936
|
||||
#: instances/jobs.py:937
|
||||
msgid "Migrate VM"
|
||||
msgstr "迁移虚拟机"
|
||||
|
||||
#: instances/jobs.py:980
|
||||
#: instances/jobs.py:981
|
||||
msgid "Migrating the VM"
|
||||
msgstr "正在迁移虚拟机"
|
||||
|
||||
#: instances/jobs.py:1027
|
||||
#: instances/jobs.py:1028
|
||||
msgid "libvirt is still migrating the VM"
|
||||
msgstr "libvirt 仍在迁移虚拟机"
|
||||
|
||||
#: instances/jobs.py:1034
|
||||
#: instances/jobs.py:1035
|
||||
msgid "The VM stayed on the source host"
|
||||
msgstr "虚拟机留在了源主机上"
|
||||
|
||||
#: instances/jobs.py:1036
|
||||
#: instances/jobs.py:1037
|
||||
msgid ""
|
||||
"The VM is on both computes or on neither; check them, then resolve this job."
|
||||
msgstr "虚拟机同时位于两个计算节点上,或都不在;请检查后处理此任务。"
|
||||
|
||||
#: instances/jobs.py:1068
|
||||
#: instances/jobs.py:1085
|
||||
msgid "Move disk"
|
||||
msgstr "移动磁盘"
|
||||
|
||||
#: instances/jobs.py:1075
|
||||
#: instances/jobs.py:1092
|
||||
msgid "Choose a disk of the VM"
|
||||
msgstr "请选择虚拟机的一个磁盘"
|
||||
|
||||
#: instances/jobs.py:1099
|
||||
#: instances/jobs.py:1116
|
||||
msgid "Copying the disk"
|
||||
msgstr "正在复制磁盘"
|
||||
|
||||
#: instances/jobs.py:1142
|
||||
#: instances/jobs.py:1159
|
||||
#, python-format
|
||||
msgid "Another block job runs on %(dev)s; check the VM"
|
||||
msgstr "%(dev)s 上正在运行另一个块任务;请检查虚拟机"
|
||||
|
||||
#: instances/jobs.py:1148
|
||||
#: instances/jobs.py:1165
|
||||
#, python-format
|
||||
msgid "The disk stayed in %(path)s; remove %(dest)s if it is there"
|
||||
msgstr "磁盘仍在 %(path)s;如果 %(dest)s 存在,请将其删除"
|
||||
|
||||
#: instances/jobs.py:1150
|
||||
#: instances/jobs.py:1167
|
||||
#, python-format
|
||||
msgid "The definitions of %(dev)s name %(paths)s; check the VM"
|
||||
msgstr "%(dev)s 的定义指向 %(paths)s;请检查虚拟机"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ ALLOWED_FOR_ANY_USER = {
|
|||
"instances:states": "the VM list's poll: only the user's own instances",
|
||||
"accounts:login": "login page",
|
||||
"accounts:email_otp": "part of the login flow (its own issues are tracked separately)",
|
||||
"accounts:otp_qr": "the OTP QR code a mailed link opens: only with that signed, hour-old-at-most link",
|
||||
"accounts:logout": "logout",
|
||||
"accounts:profile": "the user's own profile",
|
||||
"accounts:change_password": "the user's own password; new users get the permission by default",
|
||||
|
|
@ -64,6 +65,7 @@ ALLOWED_FOR_ANY_USER = {
|
|||
ALLOWED_FOR_ANONYMOUS = {
|
||||
"accounts:login": "login page",
|
||||
"accounts:email_otp": "part of the login flow (its own issues are tracked separately)",
|
||||
"accounts:otp_qr": "the OTP QR code a mailed link opens: only with that signed, hour-old-at-most link",
|
||||
"ds_openstack_index": "cloud-init datasource, fetched by VMs that do not log in",
|
||||
"ds_openstack_metadata": "cloud-init datasource, fetched by VMs that do not log in",
|
||||
"ds_openstack_userdata": "cloud-init datasource, fetched by VMs that do not log in",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue