diff --git a/core/imageroot/etc/systemd/system/support.service b/core/imageroot/etc/systemd/system/support.service index 224ec1c63..d1d496909 100644 --- a/core/imageroot/etc/systemd/system/support.service +++ b/core/imageroot/etc/systemd/system/support.service @@ -24,10 +24,17 @@ ExecStart=runagent -m node podman run \ ${SUPPORT_IMAGE} ExecStartPost=runagent -m node bash -c "nsenter -t $$(podman ps --format='{{.Pid}}' --filter=id=$$(< %t/support.cid)) -n -- nft -f $${AGENT_INSTALL_DIR}/etc/support/nft.conf" ExecStartPost=runagent -m cluster support-clusteradminctl add +ExecStartPost=/usr/bin/systemd-run \ + --collect \ + --unit=%N-expire.timer \ + --on-active=24h \ + /usr/bin/systemctl stop %n ExecStop=/usr/bin/podman stop --ignore --cidfile %t/support.cid -t 10 ExecStopPost=runagent -m node support-sshkeyctl remove ExecStopPost=runagent -m cluster support-clusteradminctl remove ExecStopPost=/usr/bin/podman rm --ignore -f --cidfile %t/support.cid +ExecStopPost=-/usr/bin/systemctl --no-block stop %N-expire.timer PIDFile=%t/support.pid Type=forking SyslogIdentifier=support +RuntimeMaxSec=7d diff --git a/core/imageroot/var/lib/nethserver/node/actions/get-support-session/20get_support_session b/core/imageroot/var/lib/nethserver/node/actions/get-support-session/20get_support_session index b82291bb8..9ab2d920b 100755 --- a/core/imageroot/var/lib/nethserver/node/actions/get-support-session/20get_support_session +++ b/core/imageroot/var/lib/nethserver/node/actions/get-support-session/20get_support_session @@ -9,6 +9,13 @@ if [[ "$(systemctl is-active support.service)" == "active" ]]; then jactive=true + if [[ "$(systemctl is-active support-expire.timer)" == "active" ]]; then + # 24 hours expiry + expires_at=$(date --utc -d "$(systemctl show --timestamp=utc -P ActiveEnterTimestamp support-expire.timer) + 24 hours" --iso-8601=seconds) + else + # 7 days expiry + expires_at=$(date --utc -d "$(systemctl show --timestamp=utc -P ActiveEnterTimestamp support.service) + 7 days" --iso-8601=seconds) + fi else jactive=false fi @@ -16,4 +23,5 @@ fi jq -c -n \ --argjson active "${jactive}" \ --arg session_id "$(grep VPN_PASSWORD support.env | cut -f 2 -d =)" \ - '{"session_id": $session_id, "active": $active}' + --arg expires_at "${expires_at}" \ + '{"session_id": $session_id, "active": $active, "expires_at": $expires_at}' diff --git a/core/imageroot/var/lib/nethserver/node/actions/get-support-session/validate-output.json b/core/imageroot/var/lib/nethserver/node/actions/get-support-session/validate-output.json index 66025b8d4..e0b46b073 100644 --- a/core/imageroot/var/lib/nethserver/node/actions/get-support-session/validate-output.json +++ b/core/imageroot/var/lib/nethserver/node/actions/get-support-session/validate-output.json @@ -6,10 +6,12 @@ "examples": [ { "session_id": "811630de-67f4-5b6d-8b2f-7cc01f592b1b", + "expires_at": "2026-03-03T15:53:22+00:00", "active": true }, { "session_id": "811630de-67f4-5b6d-8b2f-7cc01f592b1b", + "expires_at": "", "active": false } ], @@ -23,6 +25,10 @@ "type": "string", "description": "Last opened session identifier for the support team" }, + "expires_at": { + "type":"string", + "description": "Scheduled and automatic session stop time, in ISO 8601 format. An empty string is returned if support is not active." + }, "active": { "type": "boolean", "description": "If the opened session is still active or not" diff --git a/core/imageroot/var/lib/nethserver/node/bin/support-sshkeyctl b/core/imageroot/var/lib/nethserver/node/bin/support-sshkeyctl index fde9d9de0..437e628d8 100755 --- a/core/imageroot/var/lib/nethserver/node/bin/support-sshkeyctl +++ b/core/imageroot/var/lib/nethserver/node/bin/support-sshkeyctl @@ -16,7 +16,7 @@ pubkey_file=${3:-${AGENT_INSTALL_DIR:?}/etc/support/ssh-rsa.pub} function add_ssh_key { local keyopts - keyopts=$(printf 'expiry-time="%s",from="127.0.0.1"' "$(date -d 'next month' +'%Y%m%dZ')") + keyopts=$(printf 'expiry-time="%s",from="127.0.0.1"' "$(date --utc -d '7 days' +'%Y%m%d%H%M%SZ')") umask 077 printf "%s %s %s\n" "${keyopts}" "$(cat "${pubkey_file}")" "${key_comment}" >> "${authkeys_file}" } diff --git a/core/ui/public/i18n/en/translation.json b/core/ui/public/i18n/en/translation.json index d0afa9188..7f6831f1b 100644 --- a/core/ui/public/i18n/en/translation.json +++ b/core/ui/public/i18n/en/translation.json @@ -731,7 +731,7 @@ "start_session_support": "Start session", "session_id": "Session ID", "paste_session_id_to_support_team": "Paste the session ID to the support team", - "remote_support_in_progress": "Remote support session in progress", + "remote_support_in_progress": "The remote support session will end automatically in {time}. You can end it manually at any time.", "stop_session_support": "End session", "remove_cluster_subscription_title": "Remove the cluster subscription", "remove_cluster_subscription_description": "You are going to remove the subscription plan. This will disable the subscription features.", diff --git a/core/ui/public/i18n/it/translation.json b/core/ui/public/i18n/it/translation.json index 9582a52f1..35e817852 100644 --- a/core/ui/public/i18n/it/translation.json +++ b/core/ui/public/i18n/it/translation.json @@ -1615,7 +1615,7 @@ "remove_cluster_subscription_title": "Rimuovi la subscription del cluster", "no_expiration": "Senza scadenza", "paste_session_id_to_support_team": "Incolla l'ID di sessione al team di supporto", - "remote_support_in_progress": "Sessione di supporto remoto in corso", + "remote_support_in_progress": "La sessione di supporto remoto terminerà automaticamente tra {time}. Puoi terminarla manualmente in qualsiasi momento.", "remote_support": "Supporto remoto", "plan_name": "Piano", "request_subscription": "Registra", diff --git a/core/ui/src/i18n/index.js b/core/ui/src/i18n/index.js index e86c7877e..14313a05a 100644 --- a/core/ui/src/i18n/index.js +++ b/core/ui/src/i18n/index.js @@ -16,3 +16,27 @@ export async function loadLanguage(lang) { } } } + +export async function getDateFnsLocale() { + const lang = navigator.language; + try { + const mod = await import( + /* webpackChunkName: "date-fns-locale-[request]" */ + `date-fns/locale/${lang}` + ); + return mod.default || mod; + } catch { + // try base language (e.g. "it" from "it-IT") + const baseLang = lang.split("-")[0]; + try { + const mod = await import( + /* webpackChunkName: "date-fns-locale-[request]" */ + `date-fns/locale/${baseLang}` + ); + return mod.default || mod; + } catch { + const mod = await import("date-fns/locale/en-US"); + return mod.default || mod; + } + } +} \ No newline at end of file diff --git a/core/ui/src/views/settings/SettingsSubscription.vue b/core/ui/src/views/settings/SettingsSubscription.vue index 637c8ec09..b09f5b2ae 100644 --- a/core/ui/src/views/settings/SettingsSubscription.vue +++ b/core/ui/src/views/settings/SettingsSubscription.vue @@ -295,7 +295,9 @@
{{ - $t("settings_subscription.remote_support_in_progress") + $t("settings_subscription.remote_support_in_progress", { + time: formatExpiryDate(sessionExpireDate), + }) }}
{ + this.dateFnsLocale = locale; + }); }, beforeRouteEnter(to, from, next) { next((vm) => { @@ -424,6 +435,27 @@ export default { next(); }, methods: { + formatExpiryDate(dateString) { + const parsedDate = parseISO(dateString); + const duration = intervalToDuration({ + start: new Date(), + end: parsedDate, + }); + + const dateTimeStr = new Intl.DateTimeFormat(navigator.language, { + hour: "2-digit", + minute: "2-digit", + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(parsedDate); + const durationStr = formatDuration(duration, { + format: ["days", "hours"], + delimiter: ", ", + locale: this.dateFnsLocale, + }); + return `${durationStr} (${dateTimeStr})`; + }, showRemoveSubcriptionModal() { this.isShownRemoveSubcription = true; }, @@ -693,6 +725,7 @@ export default { const output = taskResult.output; this.session_id = output.session_id; this.active = output.active; + this.sessionExpireDate = output.expires_at; }, async startSessionSupport() { diff --git a/docs/core/subscription.md b/docs/core/subscription.md index 482dd5f2c..769c3acfd 100644 --- a/docs/core/subscription.md +++ b/docs/core/subscription.md @@ -80,16 +80,17 @@ with this command: systemctl start support systemctl status support -The second command prints a journal excerpt that should contain the session ID. - When a node support session is started -- support SSH key is added to the node /root/.ssh/authorized_keys file. - Only connections from the node itself are allowed with that key +- support SSH key is added to the node `/root/.ssh/authorized_keys` file + with an `expiry-time=` attribute. Only connections from the node itself + are allowed with that key. - support credentials are enabled for cluster-admin access. The user name is defined in Redis. Type `HGET cluster/subscription support_user` to see its value, by default it is `nethsupport`. The password is set to - the support session ID value + the support session ID value. `nethsupport` can connect from localhost + and a restricted set of IP addresses, coded in the + `support-clusteradminctl` command. - an OpenVPN tunnel is established with the support server, allowing connections to sshd and cluster-admin. The support server address and port can be overridden in the Redis `cluster/subscription` key @@ -100,6 +101,19 @@ When the support session is terminated - cluster-admin access is removed - SSH key is removed +The support session is automatically terminated after 24 hours. To avoid +automatic termination and allow it to run up to the maximum allowed +duration of 7 days, execute this command on the relevant node: + + systemctl stop support-expire.timer + +After 7 days the session is terminated unconditionally. + +Inspect the node session expiry with the `get-support-session` action. For +example: + + api-cli run node/7/get-support-session + ## OpenVPN support client The Systemd unit `support.service` controls an OpenVPN client running in a