{% 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{{ _('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 %}{% 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 %}{{ _('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 %} {% 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 %}{{ _('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 %} {% endwith %}{{ _("Sign-ins and every configuration change: who, from where, what — with search, filters and CSV export.") }}
{{ _("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 %} {% endwith %}| {{ _("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.") }}
{% endif %} {% set nu = submitted.new_user or {} %} {% set resettable = users|selectattr("source", "equalto", "local")|rejectattr("username", "equalto", my_name)|list %} {% if resettable %}{{ _("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 %}{{ _('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 %} {% endwith %}{{ _('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 %} {% endwith %}{{ _('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 %} {% 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.") }}
{{ _("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 %} {% endwith %}{{ _("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 %}
{{ _('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 %} {% endwith %}{{ _('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 %} {% endif %}{{ _('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 %}| {{ _("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 }} |
{{ _("⤓ 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 %}| {{ _("Mapping") }} | {{ _("Port / network") }} | {{ _("After import") }} | {{ _("Change") }} |
|---|---|---|---|
| {{ r.name|softbreak }} | {{ r.kind|softbreak }} | {%- 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.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 %}
|
| |||
{{ _("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 %}{{ _('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 %} {% if newer_writer %}{% endif %} {% if unknown_keys %}{% endif %} {% if restore_preview %} {% set rp = restore_preview %}{{ _("Sign-in and access settings it changes:") }}
{{ _("It changes no sign-in or access setting.") }}
{% endif %} {% endif %} {% if backups %}| {{ _("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 %} |
{{ _("No backups yet — the first one is made at the next configuration change.") }}
{% endif %}