{% extends "_shell.html" %} {% block title %}{% if node_name %}{{ node_name }} · {% endif %}{{ _("Settings") }} — ser2netweb{% endblock %} {% block main %} {% set is_admin = me and me.role == 'admin' %} {% set is_operator = me and me.role in ['operator', 'admin'] %} {% set is_anonymous = me and me.source == 'anonymous' %} {% set my_name = me.username if me and not is_anonymous else none %} {% set unchanged = _('•••••• (unchanged)') %} {% macro group_warning(w) -%} {%- set setting = {'admin_group': _('Admin group'), 'operator_group': _('Operator group'), 'viewer_group': _('Viewer group')}[w.setting] -%} {%- endmacro %} {% macro tls_problem(p) -%} {%- endmacro %} {#- what a form did, or why not: in the section that has the form (`section`, the id of its
; another one, at the top), which takes the keyboard focus (app.js); `submitted`: what a refused form had, shown again in it #} {% macro feedback(name) -%} {%- set where = section if section in ['account', 'admin-interface', 'access', 'audit-log', 'retention', 'users', 'ldap', 'oidc', 'cluster-settings', 'notifications', 'tls', 'reverse-proxy', 'api-token', 'pools', 'import', 'backups'] else 'account' -%} {%- if where == name -%} {% if ok %}{% endif %} {% if error %}{% endif %} {%- endif -%} {%- endmacro %} {#- a role as the role selects offer it: its name (as the REST API, the audit log and the docs write it) and what it may do, in the page's language #} {% macro role_label(r) -%} {{ r }} ({% if r == 'admin' %}{{ _("everything") }}{% elif r == 'operator' %}{{ _("manage mappings") }}{% else %}{{ _("read-only") }}{% endif %}) {%- endmacro %} {% macro package_problem(p, what) -%} {%- endmacro %}
{% if is_anonymous %}

{{ _("Signed out (open access)") }}

{{ feedback('account') }}

{{ _('Login is switched off for this UI, so you are using it without an account, with the %(role)s role. Sign in for your own role (or to change your password).', role=me.role, url=base ~ '/login') }}

{% else %}

{{ _("Change your password") }}

{{ feedback('account') }}

{{ _("Ends your sign-ins in other browsers and on other devices; this one stays signed in.") }}

{% if me and me.source == 'local' %}

{{ _("Two-factor sign-in") }}

{% if me.totp_on %}{{ _("enabled") }} {{ _("Signing in takes your password and a code from your authenticator app.") }}{% else %}{{ _("disabled") }} {{ _("Your password alone signs you in. Add a code from an authenticator app on your phone or computer, so a password alone is not enough.") }}{% endif %}

{{ _("Turn off…") if me.totp_on else _("Set up…") }} {% endif %} {% endif %}

{{ _("Admin interface") }}

{{ feedback('admin-interface') }}

{{ _('Listening on %(address)s', address=serving_address) }} {% if serving_tls %}TLS{% else %}HTTP{% endif %}

{% if pending_address %}{% endif %} {% if pending_tls %}{% endif %} {% if public_url %}

{{ _('Published as %(url)s (Settings → Reverse proxy)', url=public_url) }}

{% endif %} {% if address_hint == 'env' %}

{{ _('The address comes from SER2NETWEB_BIND_IP / SER2NETWEB_PORT (for a container: its environment), which win over config.json at every start: change them there and start ser2netweb (the container) again.') }}

{% elif address_hint == 'service' %}

{{ _('To change the address, run %(command)s from an elevated prompt: it stops the service, asks for the address and starts the service again.', command=address_command) }}

{% else %}

{{ _('To change the address, stop ser2netweb and start it as the account it runs as with %(command)s, which asks for the address; or change admin_ui.bind_ip / port in config.json and restart ser2netweb.', command=address_command) }}

{% endif %} {% if is_admin %} {% with admin = submitted.node or admin %}
{% if node_name_env %}

{{ _('Set by %(name)s for this run; the field takes effect again once that is removed.', name=node_name_env) }}

{% endif %}
{% endwith %} {% endif %}

{{ _('Server time: %(utc)s UTC · local %(local)s (%(zone)s)', utc=server_utc, local=server_local[:19], zone=server_zone()) }}

{% if clock_behind %}{% endif %}
{% if is_admin %}

{{ _("Web UI access") }}

{{ feedback('access') }}

{{ _('By default every visitor must sign in. On a trusted network you can let visitors use this UI without a password, with the role chosen below; signing in still gives a user their own role. Open access is honoured only when the UI is reached by IP address, localhost or this machine’s host name (protection against DNS rebinding — other names still ask for a login); behind a reverse proxy, have it pass the original name in X-Forwarded-Host, or keep login on. Other cluster nodes are only shown and managed after signing in, and the REST API always needs its token.') }}

{% if not admin.require_login %} {% endif %} {% with admin = submitted.access or admin, session_minutes = submitted.session_minutes or session_minutes %}
{% endwith %} {% with admin = submitted.login_notice or admin %}
{% if not admin.require_login %}

{{ _("With open access, visitors who don’t sign in find it under “Notice” in the top bar.") }}

{% endif %}
{% endwith %}

{{ _("Ends every web UI session at once by making a new session key — also sessions signed with the key in an old copy of config.json or of a backup. You stay signed in here.") }}

{{ _("Audit log") }}

{{ _("Open audit log →") }}
{{ feedback('audit-log') }}

{{ _("Sign-ins and every configuration change: who, from where, what — with search, filters and CSV export.") }}

{{ _("Retention") }}

{{ feedback('retention') }} {% if disk and disk.free is not none %} {% if disk.free < disk.floor %}{% endif %}

{{ _("The data folder’s disk: %(free)s free of %(total)s (trace files pause below %(floor)s free). Traces take %(traces)s, logs %(logs)s.", free=size_text(disk.free), total=size_text(disk.total), floor=size_text(disk.floor), traces=size_text(disk.traces), logs=size_text(disk.logs)) }}

{% endif %} {% with trace_days = submitted.retention.trace_max_age_days if submitted.retention else trace_days, log_days = submitted.retention.log_max_age_days if submitted.retention else log_days %}

{{ _("Off by default: a trace file stays until an operator deletes it in its mapping’s Trace panel. With a number of days, every trace file not written for that long is deleted at once and then every hour, also the one a mapping is writing (the next traffic begins it again), and the log says how many went.") }} {{ _("A file a mapping keeps writing into begins anew once it holds traffic from that many days ago, so its traffic goes too, that many days after its last write.") }}

{{ _("Off by default: all.log (10 MB × 6 files), audit.log (5 MB × 2) and the newest 50 configuration backups are kept within their sizes however old they are, and a mapping’s log keeps 15 days (always its last 1000 lines). With a number of days, all.log and audit.log begin a new file once their first record is a fifth of that old and delete a file once its first record is older (so at least four fifths of the period stay, sizes allowing; audit.log then keeps up to 6 files), a mapping’s log keeps no more days than that, and older backups go, but never the newest %(min)s. Checked at once and then every hour; a clock set ahead while ser2netweb runs deletes nothing early. The copy of the log on the standard output (Docker, journald) has its own limits.", min=backups_min) }}

{% endwith %}

{{ _("Users") }}

{{ feedback('users') }}
{% for u in users %} {% endfor %}
{{ _("Username") }}{{ _("Role") }}
{{ u.username }}{% if u.username == my_name %} {{ _("you") }}{% endif %}{% if u.source != 'local' %} {{ u.source|upper }}{% endif %}{% if u.totp_on %} 2FA{% endif %}
{{ _("last sign-in: %(time)s", time=last_sign_in[u.username]) }}
{% if u.source != 'local' %} {{ u.role }} ({{ _("from %(source)s", source=u.source|upper) }}) {% else %}
{% endif %}
{% if u.username != my_name %}
{% if u.totp_on %}
{% endif %} {#- asked for a local account: its password is gone with it (a directory account comes back at its next sign-in while the directory allows it) #}
{% endif %}

{{ _('Roles: admin (everything), operator (manage mappings), viewer (read-only).') }}

{% if ldap.enabled or oidc.enabled or users|rejectattr("source", "equalto", "local")|list %}

{{ _("LDAP / OIDC accounts are made at their first sign-in, and every sign-in takes their role from the directory or the identity provider again. Delete signs such an account out at once, but whether that person can sign in again is up to the directory: while a mapped group or the default role still applies, their next sign-in makes a new account.") }}

{{ _("Off by default: an account that stops signing in stays until you delete it. With a number of days, LDAP / OIDC accounts not signed in for that long are removed at once and then every hour, each logged and audited — never admins or local accounts; they come back at their next sign-in while the directory still gives them a role.") }}

{% endif %} {% set nu = submitted.new_user or {} %}
{% set resettable = users|selectattr("source", "equalto", "local")|rejectattr("username", "equalto", my_name)|list %} {% if resettable %}

{{ _("Set another user’s password") }}

{{ _("For someone who forgot theirs; it also signs that user out everywhere. “Sign out” in the table ends a user’s sessions without changing anything else.") }}

{% endif %}

LDAP / Active Directory

{{ feedback('ldap') }}

{{ _('Authenticate users against a directory; their group membership maps to a ser2netweb role. Local accounts above keep working. Needs ldap3 (optional).') }} {{ _('Setup for Active Directory and OpenLDAP: docs/SSO.md.') }}

{% if ldap_problem %}{{ package_problem(ldap_problem, _("LDAP sign-in")) }}{% endif %} {% if ldap.enabled and ldap_tls_problem %}{{ tls_problem(ldap_tls_problem) }}{% endif %} {% if ldap.enabled and ldap.uses_tls and not ldap.tls_verify %} {% elif ldap.enabled and ldap.sends_plaintext %}{% endif %} {% for w in group_warnings.ldap %}{{ group_warning(w) }}{% endfor %} {% with ldap = submitted.ldap or ldap %}

{{ _("…or search + bind with a service account:") }}

{{ _('A group setting is the whole name of one group, in any letter case (Turkish İ/ı too): its name (the CN, such as ser2netweb-admins) or its full DN. Part of a name, such as admins for ser2netweb-admins, doesn’t match.') }}

{% endwith %}

{{ _("Single sign-on (OIDC)") }}

{{ feedback('oidc') }}

{{ _('OpenID Connect SSO (Keycloak, Entra ID, Google, Okta…). A claim maps to a ser2netweb role. Needs authlib (optional).') }} {{ _('Keycloak, Entra ID and Google settings: docs/SSO.md.') }}

{% if oidc_problem %}{{ package_problem(oidc_problem, _("Single sign-on (OIDC)")) }}{% endif %}

{{ _("Redirect URI to register at the identity provider:") }} {{ oidc_redirect_uri }} ({% if oidc_redirect_public %}{{ _("Redirect URI below is blank, so it is made from the public URL (Settings → Reverse proxy)") }}{% elif oidc_redirect_auto %}{{ _("Redirect URI below is blank, so it is made from the address in this page’s request") }}{% else %}{{ _("as set below") }}{% endif %})

{% if (oidc_redirect_auto or oidc_redirect_public) and oidc_redirect_http %}{% endif %} {% for w in group_warnings.oidc %}{{ group_warning(w) }}{% endfor %} {% with oidc = submitted.oidc or oidc %}

{{ _('Both are read from the ID token only, as top-level claims: Keycloak needs a mapper with “Add to ID token” (its realm roles are nested and can’t be read), and Entra ID sends the groups’ Object IDs, which are what the group settings below then name.') }}

{{ _('A group setting is the whole name of one group as the provider sends it, in any letter case (Turkish İ/ı too); of a path, the last part is enough: ser2netweb-admins matches /team/ser2netweb-admins. Part of a name, such as admins for ser2netweb-admins, doesn’t match.') }}

{% endwith %}

{{ _("LAN cluster") }}

{{ feedback('cluster-settings') }}

{{ _('Discover other ser2netweb nodes on the local network (signed UDP broadcast — no mDNS) and show every node’s mappings in one table on the dashboard, each row tagged with its host (name + IP). Set the same shared key on every node so they trust each other; with the key, operators can start/stop/restart a peer’s mappings from the unified view. This node’s ID: %(id)s.', id=instance_id[:8]) }}

{% for c in cluster_copies %} {% endfor %} {% if new_cluster_key %} {% endif %} {% if cluster_key_hint == 'weak' %} {% elif cluster_key_hint == 'ascii' and cluster.legacy_auth %} {% endif %} {% set sc = submitted.cluster %} {% with cluster = sc or cluster, cluster_peers_text = sc.peers_text if sc else cluster_peers_text %}
{% if cluster_key_set %} {% if cluster_previous %}

{% if cluster_previous_until %}{{ _("The previous key is also accepted, until %(until)s UTC.", until=cluster_previous_until) }}{% else %}{{ _("The previous key is also accepted.") }}{% endif %}

{% endif %} {% endif %} {% if cluster.legacy_auth %}

{% if cluster_legacy %}{{ _("In use with: %(nodes)s.", nodes=cluster_legacy|join(", ")) }}{% else %}{{ _("No node has used it recently.") }}{% endif %} {{ _("Nodes running 2.8.0 or older send the key itself in every request, readable on the network; once every node is upgraded, turn this off.") }}

{% endif %}
{% endwith %}

{{ _("For a node copied from another installation (its data folder, disk or VM image): a new cluster ID and a new session key, so the copies no longer hide each other in the cluster and a sign-in to one is not valid on the other.") }}

{{ _("Notifications") }}

{{ feedback('notifications') }}

{{ _("Get a message when something needs attention — to a chat (Slack, Microsoft Teams, Discord, Telegram, or any service that accepts a JSON webhook) and/or by e-mail. The same event for the same mapping is sent at most once per repeat interval.") }}

{{ _("Messages go to the service you set here, which may be run in another country; the host name and the instance name are in every one.") }}

{% with notify = submitted.notify or notify %}
{{ _("Events") }} {% set event_labels = { 'device_lost': _("Serial device lost (unplugged, error, reconnecting)"), 'device_back': _("Serial device back"), 'start_failed': _("Mapping failed to start (e.g. port in use)"), 'link_lost': _("Connect-out mapping can't reach its remote server"), 'link_back': _("Connect-out mapping reaches its remote server again"), 'client_connected': _("Client connected"), 'client_disconnected': _("Client disconnected"), 'login_failed': _("Failed sign-in to this UI"), 'disk_low': _("Data disk almost full (traces pause)"), 'no_data': _("No data from a serial device (a mapping’s no-data alarm, set in its options)"), 'data_back': _("Data from that device again"), 'peer_offline': _("Cluster node offline (not heard from, or a configured peer that doesn’t answer)"), 'peer_back': _("Cluster node back"), 'service_started': _("ser2netweb started (which mappings run)"), 'unit_offline': _("Modbus unit stopped answering (a gateway's polling: no reply to any of its points in 3 polls in a row)"), 'unit_back': _("Modbus unit answering again"), 'cert_expiring': _("TLS certificate about to expire (the admin UI’s and each TLS mapping’s: 30, 7 and 1 days before, and once it has ended; checked daily)"), 'clock_step': _("System clock stepped while ser2netweb runs (set by hand or by time sync, by 1 s or more: the times in logs and traces jump there)"), 'safe_values': _("Safe values written (a gateway’s points with a safe value: the broker or the Sparkplug primary host was away for their safe_after_s)"), } %} {% set event_hints = { 'client_connected': _("sends the client’s IP address"), 'client_disconnected': _("sends the client’s IP address"), 'login_failed': _("sends the account name, or “(unknown user)”, and the IP address"), } %} {% for e in notify_events %} {% endfor %}
Webhook
{% if notify.webhook_url %}{% endif %}
{{ _("E-mail") }}
{% if notify.smtp_security == 'none' and notify.smtp_username %} {% endif %}
{% endwith %}

{{ _("Admin TLS (HTTPS)") }}

{{ feedback('tls') }}

{{ _("Encrypts this management UI. A change applies to new connections at once (the files are read when you save, and again when ser2netweb reloads config.json); connections already open keep theirs.") }}

{% set cert = tls_certificate %} {% if cert and cert.problem == 'unreadable' %} {% elif cert and cert.problem == 'not_pem' %} {% elif cert and cert.problem %}

{{ _('Install the cryptography package to see the certificate’s details here.') }}

{% elif cert %}

{{ _('Certificate: %(subject)s', subject=cert.subject|softbreak) }}{% if cert.self_signed %} {{ _("self-signed") }}{% endif %} {% if cert.state == 'expired' %}{{ _("expired") }}{% elif cert.state == 'not_yet_valid' %}{{ _("not valid yet") }}{% elif cert.state == 'expiring' %}{{ ngettext("ends in %(num)s day", "ends in %(num)s days", cert.days) }}{% else %}{{ ngettext("%(num)s day left", "%(num)s days left", cert.days) }}{% endif %}
{{ _("Valid from %(start)s to %(end)s UTC", start=cert.start, end=cert.end) }}{% if cert.names %} · {{ _("names: %(names)s", names=cert.names|softbreak) }}{% endif %}

{% if cert.state == 'expired' %} {% elif cert.state == 'not_yet_valid' %}{% endif %} {% endif %} {% with admin = submitted.tls or admin %}
{% if admin.tls_key_password %}{% endif %}

{{ _("With TLS, browsers that trust the certificate and reach this UI by host name use only https:// for that name, on every port, for this long after each visit: other plain-HTTP or self-signed web pages on the same name stop opening there. Before turning TLS off, set 0 and let the users open this UI once.") }}

{% endwith %}
{{ _("Upload a certificate and key from this computer…") }}

{{ _('PEM files, up to %(kb)s KB each (the certificate file may hold its chain). They are checked first (the key must be the certificate’s), then kept in the data folder’s tls/, private to ser2netweb, and set above; the paths of files already on the server keep working as before.', kb=tls_upload_kb) }}

{{ _("Reverse proxy") }}

{{ feedback('reverse-proxy') }}

{{ _('Only for a reverse proxy (nginx, Apache, Caddy, Traefik, IIS) in front of this UI; docs/REVERSE-PROXY.md has examples. Changes apply at once.') }}

{% with admin = submitted.proxy or admin %}
{% if public_url_env %}

{{ _('Set by %(name)s for this run; the field takes effect again once that is removed.', name=public_url_env) }}

{% endif %} {% if trusted_env %}

{{ _('Set by %(name)s for this run; the field takes effect again once that is removed.', name=trusted_env) }}

{% elif trusted[1] == 'FORWARDED_ALLOW_IPS' %}

{{ _('FORWARDED_ALLOW_IPS gives %(list)s while this is blank.', list=trusted[0]|join(' ')) }}

{% endif %}

{{ _("Only a connection from one of them may say who the client is (X-Forwarded-For: for the sign-in limits, the audit log and all.log) and that the browser used HTTPS (X-Forwarded-Proto). Without them every client behind a proxy has the proxy’s address.") }}

{{ _("With the max-age set under Admin TLS: browsers then use only https:// for that host name, on every port, a direct http:// address of the same name included. The proxy can send it instead.") }}

{{ _("Pages at the public URL count as this UI’s own (forms, console), a blank SSO redirect URI becomes the public URL followed by %(path)s, and notifications link to it. With a path, the UI is served under it too (the proxy passes the path on, or strips it and sends it as X-Forwarded-Prefix), and its cookies there are its own: users sign in once more.", path='/auth/oidc/callback') }}

{% endwith %}

{{ _("REST API token") }}

{{ feedback('api-token') }}

{{ _('A bearer token for the JSON API at /api/v1 (automation, Ansible, fleet tools). Send it as Authorization: Bearer <token>. Spec: openapi.json.', url=base ~ '/api/v1/openapi.json') }}

{% if new_api_token %} {% endif %}

{{ _("Status:") }} {% if api_token_set %}{{ _("enabled") }} {{ _('role: %(role)s', role=api_token_role) }} · {{ _("last used: %(time)s", time=api_token_last_used) }}{% else %}{{ _("disabled") }}{% endif %}

{% if api_token_set %} {# the same token, another role: nothing that uses it has to change #} {% else %} {% endif %}
{% if api_token_set %}
{% endif %}

{{ _("More tokens") }}

{{ _('Give each client its own token: a read-only one for monitoring, one that may change mappings for automation. Each has a name — the audit log shows it as api-token:NAME — a role and, if you like, an expiry; revoking one leaves the others working. The token above works as before. When a token was last used is known since ser2netweb started.') }}

{% if api_token_rows %}
{% for t in api_token_rows %} {% endfor %}
{{ _("Name") }}{{ _("Role") }}
{{ t.name }}{% if t.expired %} {{ _("expired") }}{% endif %}
{{ _("created %(date)s", date=t.created) }} · {% if t.expires %}{{ _("expires %(date)s", date=t.expires) }}{% else %}{{ _("never expires") }}{% endif %} · {{ _("last used: %(time)s", time=t.last_used) }}
{{ t.role }}
{% endif %} {% if api_token_rows|length < api_token_max %} {% set nt = submitted.new_token or {} %}
{% endif %}
{% endif %} {% if is_operator %}

{{ _("Port pools") }}

{{ feedback('pools') }}

{{ _("A port pool is one listening TCP port for several mappings: each client that connects to it is handed to the first mapping of the pool with a free connection, in the order of the mappings, and is that mapping’s client from then on. When every one is in use, the client is told so and disconnected. A mapping joins a pool by the pool’s name (its “Port pool” field: TCP server, raw / telnet / RFC 2217, no TLS), and keeps its own port.") }}

{% if pool_rows %}
{% for p in pool_rows %} {% endfor %}
{{ _("Pool") }}{{ _("Listens on") }}{{ _("State") }}{{ _("Mappings in use") }}{{ _("Clients handed over") }}{{ _("Clients refused") }}
{{ p.name }}{% if p.rotate %} {{ _("rotates") }}{% endif %} {{ shown_host(p.bind_ip) }}:{{ p.port }} {% if not p.enabled %}{{ _("off") }}{% elif p.state == 'listening' %}{{ _("listening") }}{% elif p.state == 'error' %}{{ _("error") }}
{{ p.last_error }}
{% else %}{{ _("stopped") }}{% endif %}
{% if p.members %}{{ _("%(busy)s of %(total)s", busy=p.busy, total=p.members|length) }} ({% for mem in p.members %}{{ mem.name }}{% if not loop.last %}, {% endif %}{% endfor %}){% else %}{{ _("no mapping names this pool") }}{% endif %} {{ p.handed }} {{ p.refused }}
{% endif %}

{{ _("Mappings export / import") }}

{{ feedback('import') }}

{{ _("⤓ Export mappings (JSON)") }}

{{ _("The export holds each mapping’s MQTT broker password in plain text: keep the file as safe as a password.") }}

{% if import_preview %} {% set p = import_preview %}

{{ _("Import preview") }} — {{ ngettext("%(num)s mapping from a %(source)s, mode: %(mode)s", "%(num)s mappings from a %(source)s, mode: %(mode)s", p.count, source=p.source, mode=_("merge") if p.mode == 'merge' else _("replace")) }}

{% for w in p.warnings %}{% endfor %} {% if p.problem %}{% endif %}
{% for r in p.rows %} {%- if r.after %}{% set a = r.after %} {%- if a.enabled %}{{ _("enabled") }}{% else %}{{ _("disabled") }}{% endif %} {%- if a.tls %} TLS{% endif %} {%- if a.read_only %} RO{% endif %} {%- if a.clients is not none %} {% if a.clients %}{{ ngettext("%(num)s allowed address", "%(num)s allowed addresses", a.clients) }}{% else %}{{ _("any client") }}{% endif %}{% endif %} {%- endif %} {%- if r.notes %} {%- endif %} {% endfor %}
{{ _("Mapping") }}{{ _("Port / network") }}{{ _("After import") }}{{ _("Change") }}
{{ r.name|softbreak }} {{ r.kind|softbreak }} {% if r.action == 'add' %}{{ _("new") }} {% elif r.action == 'update' %}{{ _("updates") }}{% if r.target != r.name %} “{{ r.target }}”{% endif %} {% elif r.action == 'same' %}{{ _("unchanged") }} {% else %}{{ _("removed") }}{% endif %} {%- if r.changes %}
    {% for c in r.changes %}{% if c.security %}⚠ {% endif %}{% if c.kept %}{{ _("%(what)s: %(value)s is kept (the file has %(file)s)", what=c.what, value=c.before, file=c.file) }}{% else %}{{ c.what }}: {{ c.before }} → {{ c.after }}{% endif %}{% endfor %}
{% endif %}
    {% for n in r.notes %}{% if n.security %}⚠ {% endif %}{{ n.text }}{% endfor %}
{{ _("Cancel") }} {% else %}

{{ _("Nothing changes until you apply the preview. The current configuration is kept as a backup either way.") }}

{{ _('Moving from the classic ser2net (stop its service first; ports below 1024; what works differently): docs/MIGRATING-FROM-SER2NET.md.') }}

{% endif %}
{% endif %} {% if is_admin %}

{{ _("Configuration backups") }}

{{ feedback('backups') }}

{{ _('Every change keeps the config.json it writes in the data folder’s backups/, and the one it replaces if that isn’t there yet (the newest 50; the one in use is marked), so the current configuration is kept there too, also when an older version takes over. Starting or stopping a mapping takes no backup of its own (the newest one is updated), so it doesn’t push out the backups of real changes. Restoring one brings back its mappings and settings after showing what changes for sign-in and access; the user accounts, the REST API token and the cluster key stay as they are unless you restore them too. A restore can be undone. Backups contain password hashes and secrets: only admins see them, and a download leaves out the session key.') }}

{% if outside_edit %}{% endif %} {% if outside_gone %}{% endif %} {% if replaced_edit and replaced_edit.backup %} {% elif replaced_edit %}{% endif %}

{{ _('config.json changed by hand or by a tool (Ansible …) is used once ser2netweb reads it again: here, with ser2netweb --reload, or on Linux with SIGHUP (systemctl reload ser2netweb). Mappings whose settings didn’t change keep running with their clients; certificate files are read again. A file that doesn’t validate changes nothing (all.log says why); a new admin UI address waits for the next start.') }}

{% if newer_writer %}{% endif %} {% if unknown_keys %}{% endif %} {% if restore_preview %} {% set rp = restore_preview %}

{{ _("Restore the configuration saved at %(time)s UTC", time=rp.time) }}

{% if rp.settings %}

{{ _("Sign-in and access settings it changes:") }}

{% else %}

{{ _("It changes no sign-in or access setting.") }}

{% endif %}
{% if rp.accounts %}
    {% for line in rp.accounts %}
  • {{ line }}
  • {% endfor %}
{% elif rp.accounts_ok %}

{{ _("Its accounts and credentials are the same as now.") }}

{% endif %} {% for note in rp.notes %}{% endfor %}
{{ _("Cancel") }}
{% endif %} {% if backups %}
{% for bk in backups %} {% endfor %}
{{ _("Saved (UTC)") }}{{ _("Contents") }}
{{ bk.time }} {{ _("%(mappings)s mappings, %(users)s users", mappings=bk.mappings if bk.mappings is not none else '?', users=bk.users if bk.users is not none else '?') }}
{{ _("written by ser2netweb %(version)s", version=bk.written_by) if bk.written_by else _("written by 2.8.1 or older") }}{% if bk.file_time %}
{{ _("saved at %(time)s UTC, when the clock was behind the newest backup", time=bk.file_time) }}{% endif %}{% if bk.current %} {{ _("in use") }}{% endif %} {% if bk.accounts %}
{{ _("Accounts") }}{% for a in bk.accounts %}{{ a.username }} ({{ a.role }}{% if a.source != 'local' %}, {{ a.source|upper }}{% endif %}){% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
{% if backup_gone_accounts %}

{{ _("Deleting a user leaves the account, with its password hash, in the backups made before. These backups hold accounts that no longer exist; removing one from them leaves everything else in each backup as it was.") }}

{% endif %}
{% else %}

{{ _("No backups yet — the first one is made at the next configuration change.") }}

{% endif %}
{% endif %} {% endblock %}