Wire up PIN authentication for remote installation

Integrate the login page, auth config, and packaging needed for
PIN-based remote access. Cockpit's CustomLoginPage only loads
login.html and po.js from the package directory; all other static
assets must be served from /usr/share/cockpit/static/, so the built
login.js and login.css are symlinked there as a separate entrypoint.

The conf.d drop-in (50-remote-auth.conf) is shipped to a staging
path and only activated at runtime by the anaconda backend when
inst.webui.remote is set with a PIN (rhinstaller/anaconda#7228).

The coproc wrapper now conditionally passes --local-session=- only
when auth is disabled, allowing cockpit-ws to delegate authentication
to the PIN handler when auth is active.

Resolves: INSTALLER-3373
This commit is contained in:
Bruno 2026-08-10 17:23:54 -03:00 committed by Bruno
parent d7d5238950
commit 66c31db454
8 changed files with 121 additions and 9 deletions

View file

@ -128,11 +128,17 @@ install: $(DIST_TEST) po/LINGUAS
cp browser-ext $(DESTDIR)/usr/libexec/anaconda
cp gnome-control-center-ext $(DESTDIR)/usr/libexec/anaconda
cp src/scripts/cockpit-coproc-wrapper.sh $(DESTDIR)/usr/libexec/anaconda/
cp src/scripts/cockpit-pin-auth $(DESTDIR)/usr/libexec/anaconda/
cp src/scripts/anaconda-cockpit-conf-merge $(DESTDIR)/usr/libexec/anaconda/
mkdir -p $(DESTDIR)/usr/lib/systemd/system/
cp src/systemd/webui-cockpit-ws.service $(DESTDIR)/usr/lib/systemd/system/
cp src/systemd/cockpit-pin-auth@.service $(DESTDIR)/usr/lib/systemd/system/
cp src/systemd/cockpit-pin-auth.socket $(DESTDIR)/usr/lib/systemd/system/
mkdir -p $(DESTDIR)/etc/anaconda/cockpit/conf.d/
cp src/config/cockpit/cockpit.conf $(DESTDIR)/etc/anaconda/cockpit/cockpit.conf
# Staged config, not active by default — webui-desktop copies it to conf.d/ at runtime
mkdir -p $(DESTDIR)/usr/share/anaconda/cockpit/conf.d/
cp src/config/cockpit/conf.d/50-remote-auth.conf $(DESTDIR)/usr/share/anaconda/cockpit/conf.d/
dist: $(TARFILE)
@ls -1 $(TARFILE)

View file

@ -49,7 +49,7 @@ function watchDirs (dir, onChange) {
const context = await esbuild.context({
bundle: true,
entryPoints: ["./src/index.js"],
entryPoints: ["./src/index.js", "./src/anacondaLogin.js"],
external: ["*.woff", "*.woff2", "*.jpg", "*.svg", "../../assets*"],
legalComments: "external", // Move all legal comments to a .LEGAL.txt file
loader: {
@ -69,6 +69,7 @@ const context = await esbuild.context({
{ from: ["./images/qr-code-feedback.svg"], to: ["./qr-code-feedback.svg"] },
{ from: ["./src/manifest.json"], to: ["./manifest.json"] },
{ from: ["./src/index.html"], to: ["./index.html"] },
{ from: ["./src/login.html"], to: ["./login.html"] },
{ from: ["./VERSION.txt"], to: ["./VERSION.txt"] },
]
}),
@ -77,7 +78,9 @@ const context = await esbuild.context({
quietDeps: true,
}),
cockpitPoEsbuildPlugin(),
cockpitCompressPlugin(),
// Login assets are served via /cockpit/static/ which does not
// support .gz transparent serving, causing MIME type mismatch.
cockpitCompressPlugin({ exclude: /anacondaLogin\.(js|css)$/ }),
cockpitRsyncEsbuildPlugin({ dest: packageJson.name }),
{

View file

@ -65,7 +65,13 @@ exit 0
%files
%dir %{_datadir}/cockpit/anaconda-webui
%doc README.rst
%license LICENSE dist/index.js.LEGAL.txt
%license LICENSE dist/index.js.LEGAL.txt dist/anacondaLogin.js.LEGAL.txt
%{_datadir}/cockpit/anaconda-webui/login.html
%{_datadir}/cockpit/anaconda-webui/anacondaLogin.js
%{_datadir}/cockpit/anaconda-webui/anacondaLogin.js.map
%{_datadir}/cockpit/anaconda-webui/anacondaLogin.js.LEGAL.txt
%{_datadir}/cockpit/anaconda-webui/anacondaLogin.css
%{_datadir}/cockpit/anaconda-webui/anacondaLogin.css.map
%{_datadir}/cockpit/anaconda-webui/qr-code-feedback.svg
%{_datadir}/cockpit/anaconda-webui/index.js.LEGAL.txt
%{_datadir}/cockpit/anaconda-webui/index.html
@ -81,6 +87,10 @@ exit 0
%dir /etc/anaconda/cockpit
%config(noreplace) /etc/anaconda/cockpit/cockpit.conf
%dir /etc/anaconda/cockpit/conf.d
%dir %{_datadir}/anaconda/cockpit
%dir %{_datadir}/anaconda/cockpit/conf.d
%{_datadir}/anaconda/cockpit/conf.d/50-remote-auth.conf
%{_libexecdir}/anaconda/cockpit-pin-auth
%dir %{_datadir}/anaconda/firefox-theme
%dir %{_datadir}/anaconda/firefox-theme/default
%dir %{_datadir}/anaconda/firefox-theme/default/chrome
@ -98,6 +108,8 @@ exit 0
%{_datadir}/applications/extlinks.desktop
%{_datadir}/applications/anaconda-gnome-control-center.desktop
%{_unitdir}/webui-cockpit-ws.service
%{_unitdir}/cockpit-pin-auth.socket
%{_unitdir}/cockpit-pin-auth@.service
# The changelog is automatically generated and merged

View file

@ -0,0 +1,15 @@
# Copyright (C) 2026 Red Hat, Inc.
# SPDX-License-Identifier: LGPL-2.1-or-later
#
# Cockpit PIN authentication for remote WebUI access.
# This file is NOT active by default. It is shipped to
# /usr/share/anaconda/cockpit/conf.d/ and only copied into the
# active /etc/anaconda/cockpit/conf.d/ at runtime by webui-desktop
# when inst.webui.remote is set with a password and
# inst.webui.remote.noauth is not set.
[WebService]
CustomLoginPage = /usr/share/cockpit/anaconda-webui/
[Basic]
Command = /usr/libexec/anaconda/cockpit-pin-auth

View file

@ -12,6 +12,11 @@ WEBUI_ADDRESS=$1
# Start cockpit-bridge in unconfined context via su using coproc
coproc BRIDGE { cockpit-bridge; }
# Start cockpit-ws connected to the coproc
# TLS settings are now in /etc/anaconda/cockpit/cockpit.conf (AllowUnencrypted)
exec /usr/libexec/cockpit-ws -p 80 -a "$WEBUI_ADDRESS" --local-session=- <&${BRIDGE[0]} >&${BRIDGE[1]}
# When auth is not enabled, use --local-session=- to connect
# directly to the bridge (skips cockpit authentication).
WS_EXTRA_ARGS=()
if [[ "${WEBUI_AUTH:-0}" != "1" ]]; then
WS_EXTRA_ARGS+=(--local-session=-)
fi
exec /usr/libexec/cockpit-ws "${WS_EXTRA_ARGS[@]}" -p 80 -a "$WEBUI_ADDRESS" <&${BRIDGE[0]} >&${BRIDGE[1]}

53
test/check-remote-auth Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/python3
#
# Copyright (C) 2026 Red Hat, Inc.
# SPDX-License-Identifier: LGPL-2.1-or-later
from anacondalib import INSTALLER_VM_MEMORY_MB, VirtInstallMachineCase
from installer import Installer
from testlib import test_main # pylint: disable=import-error
class TestRemoteAuth(VirtInstallMachineCase):
provision = {
"machine1": {
"remote_pin": "test1234",
"memory_mb": INSTALLER_VM_MEMORY_MB,
},
}
def testBasic(self):
"""
Description:
Boot with ``inst.webui.remote inst.webui.remote.password=test1234``
and verify that PIN authentication is required.
Expected results:
- The login page is shown
- Wrong PIN is rejected with an error
- Correct PIN grants access to the installer
"""
b = self.browser
m = self.machine
_i = Installer(b, m)
b.open("/cockpit/@localhost/anaconda-webui/index.html")
# Login page should be visible
b.wait_visible("#pin-input")
# Wrong password should be rejected
b.set_input_text("#pin-input", "wrongpin")
b.click("button[type=submit]")
b.wait_in_text(".pf-v6-c-alert", "Invalid PIN")
# Correct password should grant access
b.set_input_text("#pin-input", "test1234")
b.click("button[type=submit]")
# After login redirect, the installer loads at the root path
b.wait_js_cond('window.location.pathname === "/cockpit/@localhost/anaconda-webui/index.html"')
if __name__ == "__main__":
test_main()

View file

@ -45,6 +45,7 @@ class VirtInstallMachine(VirtMachine):
self.kickstart_file_name = kwargs.pop("kickstart_file_name", None)
self.pause_at_summary = kwargs.pop("pause_at_summary", False)
self.payload_type = kwargs.pop("payload_type", "liveimg".lower())
self.remote_pin = kwargs.pop("remote_pin", "")
# Always enforce the minimum RAM the installer requires. Covers every entry point the same way:
# test/run (CI), local test/check-*, and GlobalMachine.reset() which omits memory_mb.
kwargs["memory_mb"] = max(kwargs.get("memory_mb") or 0, INSTALLER_VM_MEMORY_MB)
@ -194,6 +195,7 @@ class VirtInstallMachine(VirtMachine):
if self.console_file
else ""
)
auth_method = f"inst.webui.remote.pin={self.remote_pin}" if self.remote_pin else "inst.webui.remote.noauth"
try:
self._execute(
"virt-install "
@ -208,7 +210,7 @@ class VirtInstallMachine(VirtMachine):
f"{serial_opt}"
f"--graphics vnc,listen={self.ssh_address} "
"--extra-args "
f"'{virt_kargs}{inst_graphical}inst.sshd inst.webui inst.webui.remote {selinux}{inst_ks_arg} "
f"'{virt_kargs}{inst_graphical}inst.sshd inst.webui inst.webui.remote {auth_method} {selinux}{inst_ks_arg} "
f"inst.updates=http://10.0.2.2:{self.http_install_port}/"
f"{self.label}-updates.img' "
"--network none "

View file

@ -34,13 +34,15 @@ BROWSER_HOME=""
THEME_ID="default"
WEBUI_REMOTE=0
while getopts t:r: option
WEBUI_NOAUTH=0
while getopts t:r:n: option
do
case "${option}"
in
t)THEME_ID=${OPTARG};;
r)WEBUI_REMOTE=${OPTARG};;
*) echo "Usage: $0 [-t THEME_ID] [-r WEBUI_REMOTE]" >&2
n)WEBUI_NOAUTH=${OPTARG};;
*) echo "Usage: $0 [-t THEME_ID] [-r WEBUI_REMOTE] [-n WEBUI_NOAUTH]" >&2
exit 1 ;;
esac
done
@ -53,7 +55,21 @@ then
WEBUI_ADDRESS="0.0.0.0"
fi
REMOTE_AUTH_DROPIN="/etc/anaconda/cockpit/conf.d/50-remote-auth.conf"
REMOTE_AUTH_SOURCE="/usr/share/anaconda/cockpit/conf.d/50-remote-auth.conf"
PIN_FILE="/run/anaconda/remote-pin"
WEBUI_AUTH=0
if [[ -f "$PIN_FILE" ]]; then
cp "$REMOTE_AUTH_SOURCE" "$REMOTE_AUTH_DROPIN"
WEBUI_AUTH=1
else
rm -f "$REMOTE_AUTH_DROPIN"
fi
echo "WEBUI_ADDRESS=$WEBUI_ADDRESS" > /tmp/webui-cockpit-ws.env
echo "WEBUI_AUTH=$WEBUI_AUTH" >> /tmp/webui-cockpit-ws.env
systemctl start webui-cockpit-ws
# Function to setup user-related environment