From 690469c4b5476d12338335ae5eb5e238ff3147b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85slund?= Date: Fri, 16 Oct 2026 18:38:38 +0000 Subject: [PATCH 1/6] bootstrap: key-only platform user gets passwordless sudo (validated sudoers drop-in) A key-only platform user (no password in the platform) cannot answer sudo's prompt, so Ansible become and other automation over SSH could never act as root. setup_user now writes /etc/sudoers.d/90-teknoir- with NOPASSWD for exactly that user, mode 0440, checked with visudo -cf before it is moved into place; with a password set the drop-in is removed again. Golden files regenerated (UPDATE_GOLDEN=1 go test in pkg/templates). Co-Authored-By: Claude Opus 5.5 --- pkg/templates/partials/user_setup.sh.tmpl | 30 +++++++++++++++++++ pkg/templates/render_test.go | 5 ++++ pkg/templates/testdata/agent.golden.sh | 30 +++++++++++++++++++ .../testdata/docker_server.golden.sh | 30 +++++++++++++++++++ pkg/templates/testdata/server.golden.sh | 30 +++++++++++++++++++ 5 files changed, 125 insertions(+) diff --git a/pkg/templates/partials/user_setup.sh.tmpl b/pkg/templates/partials/user_setup.sh.tmpl index d7646e2..ac4e63d 100644 --- a/pkg/templates/partials/user_setup.sh.tmpl +++ b/pkg/templates/partials/user_setup.sh.tmpl @@ -54,4 +54,34 @@ if [ -e /etc/group ]; then sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group fi EOF + + setup_user_sudo +} + +# A key-only platform user (no password in the platform) cannot answer sudo's +# password prompt, so automation over SSH (Ansible become, tnctl) could never +# act as root. Give exactly that user passwordless sudo with a validated +# drop-in. This is no weaker than a password: the SSH key and any password +# live in the same platform record, and the key alone already logs in. With a +# password set, the drop-in is removed again and sudo asks for the password. +setup_user_sudo() { + _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + if [ -n "${_FIRST_USER_PASS}" ]; then + $SUDO rm -f "${_tn_sudoers}" + return 0 + fi + if [ ! -d /etc/sudoers.d ]; then + warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" + return 0 + fi + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) + echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null + $SUDO chmod 0440 "${_tn_tmp}" + if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + $SUDO rm -f "${_tn_tmp}" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + return 0 + fi + $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" + info "Key-only login: ${_FIRST_USER_NAME} has passwordless sudo (${_tn_sudoers})" } diff --git a/pkg/templates/render_test.go b/pkg/templates/render_test.go index 15507ff..76dec55 100644 --- a/pkg/templates/render_test.go +++ b/pkg/templates/render_test.go @@ -32,6 +32,11 @@ func TestGolden_Execute(t *testing.T) { if err := tt.exec(&buf); err != nil { t.Fatal(err) } + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile("testdata/"+tt.name+".golden.sh", buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + } if want := readGolden(t, tt.name); !bytes.Equal(buf.Bytes(), want) { t.Fatalf("%s output differs from testdata/%s.golden.sh (got %d bytes, want %d)", tt.name, tt.name, buf.Len(), len(want)) } diff --git a/pkg/templates/testdata/agent.golden.sh b/pkg/templates/testdata/agent.golden.sh index 01b207b..23a3759 100644 --- a/pkg/templates/testdata/agent.golden.sh +++ b/pkg/templates/testdata/agent.golden.sh @@ -870,6 +870,36 @@ if [ -e /etc/group ]; then sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group fi EOF + + setup_user_sudo +} + +# A key-only platform user (no password in the platform) cannot answer sudo's +# password prompt, so automation over SSH (Ansible become, tnctl) could never +# act as root. Give exactly that user passwordless sudo with a validated +# drop-in. This is no weaker than a password: the SSH key and any password +# live in the same platform record, and the key alone already logs in. With a +# password set, the drop-in is removed again and sudo asks for the password. +setup_user_sudo() { + _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + if [ -n "${_FIRST_USER_PASS}" ]; then + $SUDO rm -f "${_tn_sudoers}" + return 0 + fi + if [ ! -d /etc/sudoers.d ]; then + warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" + return 0 + fi + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) + echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null + $SUDO chmod 0440 "${_tn_tmp}" + if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + $SUDO rm -f "${_tn_tmp}" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + return 0 + fi + $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" + info "Key-only login: ${_FIRST_USER_NAME} has passwordless sudo (${_tn_sudoers})" } diff --git a/pkg/templates/testdata/docker_server.golden.sh b/pkg/templates/testdata/docker_server.golden.sh index 1b89a6a..3cd86f1 100644 --- a/pkg/templates/testdata/docker_server.golden.sh +++ b/pkg/templates/testdata/docker_server.golden.sh @@ -985,6 +985,36 @@ if [ -e /etc/group ]; then sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group fi EOF + + setup_user_sudo +} + +# A key-only platform user (no password in the platform) cannot answer sudo's +# password prompt, so automation over SSH (Ansible become, tnctl) could never +# act as root. Give exactly that user passwordless sudo with a validated +# drop-in. This is no weaker than a password: the SSH key and any password +# live in the same platform record, and the key alone already logs in. With a +# password set, the drop-in is removed again and sudo asks for the password. +setup_user_sudo() { + _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + if [ -n "${_FIRST_USER_PASS}" ]; then + $SUDO rm -f "${_tn_sudoers}" + return 0 + fi + if [ ! -d /etc/sudoers.d ]; then + warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" + return 0 + fi + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) + echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null + $SUDO chmod 0440 "${_tn_tmp}" + if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + $SUDO rm -f "${_tn_tmp}" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + return 0 + fi + $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" + info "Key-only login: ${_FIRST_USER_NAME} has passwordless sudo (${_tn_sudoers})" } setup_user diff --git a/pkg/templates/testdata/server.golden.sh b/pkg/templates/testdata/server.golden.sh index ea485d5..8d09764 100644 --- a/pkg/templates/testdata/server.golden.sh +++ b/pkg/templates/testdata/server.golden.sh @@ -3215,6 +3215,36 @@ if [ -e /etc/group ]; then sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group fi EOF + + setup_user_sudo +} + +# A key-only platform user (no password in the platform) cannot answer sudo's +# password prompt, so automation over SSH (Ansible become, tnctl) could never +# act as root. Give exactly that user passwordless sudo with a validated +# drop-in. This is no weaker than a password: the SSH key and any password +# live in the same platform record, and the key alone already logs in. With a +# password set, the drop-in is removed again and sudo asks for the password. +setup_user_sudo() { + _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + if [ -n "${_FIRST_USER_PASS}" ]; then + $SUDO rm -f "${_tn_sudoers}" + return 0 + fi + if [ ! -d /etc/sudoers.d ]; then + warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" + return 0 + fi + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) + echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null + $SUDO chmod 0440 "${_tn_tmp}" + if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + $SUDO rm -f "${_tn_tmp}" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + return 0 + fi + $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" + info "Key-only login: ${_FIRST_USER_NAME} has passwordless sudo (${_tn_sudoers})" } From 55a08ada3df5faa798f22475336237352373f3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85slund?= Date: Fri, 16 Oct 2026 18:41:48 +0000 Subject: [PATCH 2/6] ansible-inventory: groups hold only reachable hosts; ansible_teknoir_login_user host var Co-Authored-By: Claude Opus 5.5 --- cmd/ansible_inventory.go | 15 ++++++++++++++- cmd/ansible_inventory_test.go | 27 ++++++++++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/cmd/ansible_inventory.go b/cmd/ansible_inventory.go index 36e0428..95c30d6 100644 --- a/cmd/ansible_inventory.go +++ b/cmd/ansible_inventory.go @@ -242,6 +242,7 @@ func buildInventory(devices []api.BackstageDevice, o inventoryOpts, warn io.Writ "ansible_kubectl_namespace": ns, "ansible_teknoir_namespace": ns, "ansible_teknoir_device": name, + "ansible_teknoir_login_user": username, "ansible_teknoir_domain": o.Domain, "ansible_teknoir_transport": transportWireGuard, }, password) @@ -269,6 +270,7 @@ func buildInventory(devices []api.BackstageDevice, o inventoryOpts, warn io.Writ "ansible_teknoir_tunnel_port": portVal, "ansible_teknoir_tunnel_open": true, "ansible_teknoir_device": name, + "ansible_teknoir_login_user": username, "ansible_teknoir_domain": o.Domain, "ansible_teknoir_transport": transportTunnel, }, password) @@ -281,7 +283,18 @@ func buildInventory(devices []api.BackstageDevice, o inventoryOpts, warn io.Writ } sort.Strings(names) for _, g := range names { - inv[g] = map[string]interface{}{"hosts": groups[g], "vars": map[string]interface{}{}} + // Only hosts Ansible can reach: a device with no transport (no tunnel, + // no active WireGuard, no login) would be resolved by name and fail. + var hosts []string + for _, h := range groups[g] { + if _, ok := hostvars[h]; ok { + hosts = append(hosts, h) + } + } + if len(hosts) == 0 { + continue + } + inv[g] = map[string]interface{}{"hosts": hosts, "vars": map[string]interface{}{}} } return inv, nil } diff --git a/cmd/ansible_inventory_test.go b/cmd/ansible_inventory_test.go index 6ebc9db..9942103 100644 --- a/cmd/ansible_inventory_test.go +++ b/cmd/ansible_inventory_test.go @@ -136,16 +136,29 @@ func TestBuildInventoryTransports(t *testing.T) { t.Errorf("%s transport = %v, want %s", h, got, tr) } } - // groups: every device listed regardless of transport + // groups: only hosts with a transport (Ansible would try to resolve + // the others by name) g := inv["teknoir_demo"].(map[string]interface{})["hosts"].([]string) - if len(g) != 5 { - t.Errorf("namespace group hosts = %v", g) + if len(g) != len(c.want) { + t.Errorf("namespace group hosts = %v, want %d", g, len(c.want)) } - if !reflect.DeepEqual(inv["device_group_test"].(map[string]interface{})["hosts"], []string{"wg1", "tun1"}) { - t.Errorf("label group = %v", inv["device_group_test"]) + for _, h := range g { + if _, ok := c.want[h]; !ok { + t.Errorf("unreachable host %s in a group", h) + } + } + var wantLabel []string + for _, h := range []string{"wg1", "tun1"} { + if _, ok := c.want[h]; ok { + wantLabel = append(wantLabel, h) + } + } + if !reflect.DeepEqual(inv["device_group_test"].(map[string]interface{})["hosts"], wantLabel) { + t.Errorf("label group = %v, want %v", inv["device_group_test"], wantLabel) } - if _, ok := inv["site_name_a_b"]; !ok { - t.Errorf("sanitised label group missing: %v", inv) + _, ok := inv["site_name_a_b"] + if _, wg1 := c.want["wg1"]; ok != wg1 { + t.Errorf("sanitised label group present = %v, want %v", ok, wg1) } }) } From e866514efbca5e5ff691dfa114c4ddc3d3218b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85slund?= Date: Fri, 16 Oct 2026 18:57:34 +0000 Subject: [PATCH 3/6] bootstrap: add the platform user to users/sudo only once; clearer key message Every run appended the user to the users and sudo lines of /etc/group again (rpi4-8gb-black-se had it 12 times). Use usermod -aG when the user is not yet a member, with the old sed as a fallback. The message for a missing extra key no longer claims password-only login: the device key authorizes logins. Co-Authored-By: Claude Opus 5.5 --- pkg/templates/partials/user_setup.sh.tmpl | 17 +++++++++++------ pkg/templates/testdata/agent.golden.sh | 17 +++++++++++------ pkg/templates/testdata/docker_server.golden.sh | 17 +++++++++++------ pkg/templates/testdata/server.golden.sh | 17 +++++++++++------ 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/pkg/templates/partials/user_setup.sh.tmpl b/pkg/templates/partials/user_setup.sh.tmpl index ac4e63d..fa18e24 100644 --- a/pkg/templates/partials/user_setup.sh.tmpl +++ b/pkg/templates/partials/user_setup.sh.tmpl @@ -40,7 +40,7 @@ cp "${CONFIG_PATH}/rsa_private.pem" "/home/${_FIRST_USER_NAME}/.ssh/id_rsa" ssh-keygen -y -f "${CONFIG_PATH}/rsa_private.pem" > "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" cat "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" > "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" if [ -z "\${_FIRST_USER_KEY+x}" ] || [ "\${_FIRST_USER_KEY}" = "" ]; then - echo "[INFO] _FIRST_USER_KEY is not set, will continue with only password auth"; + echo "[INFO] No extra SSH key for the user in the platform; the device key authorizes logins"; else echo "\${_FIRST_USER_KEY}" >> "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" fi @@ -48,11 +48,16 @@ chmod 600 /home/${_FIRST_USER_NAME}/.ssh/* chmod 744 /home/${_FIRST_USER_NAME}/.ssh chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" -# Add root user to users and sudo groups -if [ -e /etc/group ]; then - sed -i "/^users:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group - sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group -fi +# Add the user to the users and sudo groups (once: a re-run must not list it twice) +for _tn_group in users sudo; do + if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if command -v usermod > /dev/null 2>&1; then + usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" + else + sed -i "/^\${_tn_group}:/s/\\(.*\\)/\\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group + fi + fi +done EOF setup_user_sudo diff --git a/pkg/templates/testdata/agent.golden.sh b/pkg/templates/testdata/agent.golden.sh index 23a3759..e9c5c2a 100644 --- a/pkg/templates/testdata/agent.golden.sh +++ b/pkg/templates/testdata/agent.golden.sh @@ -856,7 +856,7 @@ cp "${CONFIG_PATH}/rsa_private.pem" "/home/${_FIRST_USER_NAME}/.ssh/id_rsa" ssh-keygen -y -f "${CONFIG_PATH}/rsa_private.pem" > "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" cat "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" > "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" if [ -z "\${_FIRST_USER_KEY+x}" ] || [ "\${_FIRST_USER_KEY}" = "" ]; then - echo "[INFO] _FIRST_USER_KEY is not set, will continue with only password auth"; + echo "[INFO] No extra SSH key for the user in the platform; the device key authorizes logins"; else echo "\${_FIRST_USER_KEY}" >> "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" fi @@ -864,11 +864,16 @@ chmod 600 /home/${_FIRST_USER_NAME}/.ssh/* chmod 744 /home/${_FIRST_USER_NAME}/.ssh chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" -# Add root user to users and sudo groups -if [ -e /etc/group ]; then - sed -i "/^users:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group - sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group -fi +# Add the user to the users and sudo groups (once: a re-run must not list it twice) +for _tn_group in users sudo; do + if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if command -v usermod > /dev/null 2>&1; then + usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" + else + sed -i "/^\${_tn_group}:/s/\\(.*\\)/\\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group + fi + fi +done EOF setup_user_sudo diff --git a/pkg/templates/testdata/docker_server.golden.sh b/pkg/templates/testdata/docker_server.golden.sh index 3cd86f1..0787169 100644 --- a/pkg/templates/testdata/docker_server.golden.sh +++ b/pkg/templates/testdata/docker_server.golden.sh @@ -971,7 +971,7 @@ cp "${CONFIG_PATH}/rsa_private.pem" "/home/${_FIRST_USER_NAME}/.ssh/id_rsa" ssh-keygen -y -f "${CONFIG_PATH}/rsa_private.pem" > "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" cat "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" > "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" if [ -z "\${_FIRST_USER_KEY+x}" ] || [ "\${_FIRST_USER_KEY}" = "" ]; then - echo "[INFO] _FIRST_USER_KEY is not set, will continue with only password auth"; + echo "[INFO] No extra SSH key for the user in the platform; the device key authorizes logins"; else echo "\${_FIRST_USER_KEY}" >> "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" fi @@ -979,11 +979,16 @@ chmod 600 /home/${_FIRST_USER_NAME}/.ssh/* chmod 744 /home/${_FIRST_USER_NAME}/.ssh chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" -# Add root user to users and sudo groups -if [ -e /etc/group ]; then - sed -i "/^users:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group - sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group -fi +# Add the user to the users and sudo groups (once: a re-run must not list it twice) +for _tn_group in users sudo; do + if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if command -v usermod > /dev/null 2>&1; then + usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" + else + sed -i "/^\${_tn_group}:/s/\\(.*\\)/\\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group + fi + fi +done EOF setup_user_sudo diff --git a/pkg/templates/testdata/server.golden.sh b/pkg/templates/testdata/server.golden.sh index 8d09764..bd703fe 100644 --- a/pkg/templates/testdata/server.golden.sh +++ b/pkg/templates/testdata/server.golden.sh @@ -3201,7 +3201,7 @@ cp "${CONFIG_PATH}/rsa_private.pem" "/home/${_FIRST_USER_NAME}/.ssh/id_rsa" ssh-keygen -y -f "${CONFIG_PATH}/rsa_private.pem" > "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" cat "/home/${_FIRST_USER_NAME}/.ssh/id_rsa.pub" > "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" if [ -z "\${_FIRST_USER_KEY+x}" ] || [ "\${_FIRST_USER_KEY}" = "" ]; then - echo "[INFO] _FIRST_USER_KEY is not set, will continue with only password auth"; + echo "[INFO] No extra SSH key for the user in the platform; the device key authorizes logins"; else echo "\${_FIRST_USER_KEY}" >> "/home/${_FIRST_USER_NAME}/.ssh/authorized_keys" fi @@ -3209,11 +3209,16 @@ chmod 600 /home/${_FIRST_USER_NAME}/.ssh/* chmod 744 /home/${_FIRST_USER_NAME}/.ssh chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" -# Add root user to users and sudo groups -if [ -e /etc/group ]; then - sed -i "/^users:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group - sed -i "/^sudo:/s/\(.*\)/\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group -fi +# Add the user to the users and sudo groups (once: a re-run must not list it twice) +for _tn_group in users sudo; do + if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if command -v usermod > /dev/null 2>&1; then + usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" + else + sed -i "/^\${_tn_group}:/s/\\(.*\\)/\\1,${_FIRST_USER_NAME}/;s/:,/:/" /etc/group + fi + fi +done EOF setup_user_sudo From ad82a6649feb5dc1e2d72ddfc236968750b13e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85slund?= Date: Fri, 16 Oct 2026 19:08:40 +0000 Subject: [PATCH 4/6] bootstrap: warn about creating the user only when asking With CREATE_USER=true (or an OS build) the user is created without a prompt, so the 'has to be created' warning was noise in every unattended run. Co-Authored-By: Claude Opus 5.5 --- pkg/templates/agent.sh.tmpl | 2 -- pkg/templates/docker_server.sh.tmpl | 2 -- pkg/templates/partials/user_invocation.sh.tmpl | 1 + pkg/templates/server.sh.tmpl | 2 -- pkg/templates/testdata/agent.golden.sh | 3 +-- pkg/templates/testdata/docker_server.golden.sh | 2 -- pkg/templates/testdata/server.golden.sh | 3 +-- 7 files changed, 3 insertions(+), 12 deletions(-) diff --git a/pkg/templates/agent.sh.tmpl b/pkg/templates/agent.sh.tmpl index 65db83a..103c2b8 100644 --- a/pkg/templates/agent.sh.tmpl +++ b/pkg/templates/agent.sh.tmpl @@ -78,8 +78,6 @@ tn_inst_finish info "K3s agent node joined successfully!" -warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." - {{template "user_setup.sh.tmpl" .}} {{template "user_invocation.sh.tmpl" .}} diff --git a/pkg/templates/docker_server.sh.tmpl b/pkg/templates/docker_server.sh.tmpl index c2d59c8..d37450a 100644 --- a/pkg/templates/docker_server.sh.tmpl +++ b/pkg/templates/docker_server.sh.tmpl @@ -56,8 +56,6 @@ info "Device bootstrapped successfully!" info "Notice!" info "No networking or user settings has been changed to match settings in the platform." -warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." - {{template "user_setup.sh.tmpl" .}} setup_user diff --git a/pkg/templates/partials/user_invocation.sh.tmpl b/pkg/templates/partials/user_invocation.sh.tmpl index d5db2c0..5f16af2 100644 --- a/pkg/templates/partials/user_invocation.sh.tmpl +++ b/pkg/templates/partials/user_invocation.sh.tmpl @@ -2,6 +2,7 @@ CREATE_USER=${CREATE_USER:-ask} if [ "${OS_BUILD}" ] || [ "${CREATE_USER}" = true ]; then setup_user else + warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." if id -u "${_FIRST_USER_NAME}" >/dev/null 2>&1; then warn "The user ${_FIRST_USER_NAME} already exist! This operation will change the password and OVERWRITE ssh keys for this users." fi diff --git a/pkg/templates/server.sh.tmpl b/pkg/templates/server.sh.tmpl index ebf6494..392004b 100644 --- a/pkg/templates/server.sh.tmpl +++ b/pkg/templates/server.sh.tmpl @@ -467,8 +467,6 @@ info "Device bootstrapped successfully!" info "Notice!" info "No networking or user settings has been changed to match settings in the platform." -warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." - {{template "user_setup.sh.tmpl" .}} {{template "user_invocation.sh.tmpl" .}} diff --git a/pkg/templates/testdata/agent.golden.sh b/pkg/templates/testdata/agent.golden.sh index e9c5c2a..79b430d 100644 --- a/pkg/templates/testdata/agent.golden.sh +++ b/pkg/templates/testdata/agent.golden.sh @@ -812,8 +812,6 @@ tn_inst_finish info "K3s agent node joined successfully!" -warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." - on_sudo() { $SUDO sh -- "$@" } @@ -912,6 +910,7 @@ CREATE_USER=${CREATE_USER:-ask} if [ "${OS_BUILD}" ] || [ "${CREATE_USER}" = true ]; then setup_user else + warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." if id -u "${_FIRST_USER_NAME}" >/dev/null 2>&1; then warn "The user ${_FIRST_USER_NAME} already exist! This operation will change the password and OVERWRITE ssh keys for this users." fi diff --git a/pkg/templates/testdata/docker_server.golden.sh b/pkg/templates/testdata/docker_server.golden.sh index 0787169..d801ad2 100644 --- a/pkg/templates/testdata/docker_server.golden.sh +++ b/pkg/templates/testdata/docker_server.golden.sh @@ -927,8 +927,6 @@ info "" info "Notice!" info "No networking or user settings has been changed to match settings in the platform." -warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." - on_sudo() { $SUDO sh -- "$@" } diff --git a/pkg/templates/testdata/server.golden.sh b/pkg/templates/testdata/server.golden.sh index bd703fe..6b2cbb4 100644 --- a/pkg/templates/testdata/server.golden.sh +++ b/pkg/templates/testdata/server.golden.sh @@ -3157,8 +3157,6 @@ info "" info "Notice!" info "No networking or user settings has been changed to match settings in the platform." -warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." - on_sudo() { $SUDO sh -- "$@" } @@ -3257,6 +3255,7 @@ CREATE_USER=${CREATE_USER:-ask} if [ "${OS_BUILD}" ] || [ "${CREATE_USER}" = true ]; then setup_user else + warn "To enable tunneling, the user ${_FIRST_USER_NAME} has to be created." if id -u "${_FIRST_USER_NAME}" >/dev/null 2>&1; then warn "The user ${_FIRST_USER_NAME} already exist! This operation will change the password and OVERWRITE ssh keys for this users." fi From 041a61470aed78b4d3c0a067b8aedc542758670d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85slund?= Date: Fri, 16 Oct 2026 19:20:55 +0000 Subject: [PATCH 5/6] bootstrap: sudoers rule by uid in one fixed drop-in; validate the login name; inventory reports left-out devices Review findings on the passwordless-sudo change: - The rule is '# ALL=(ALL:ALL) NOPASSWD: ALL' in /etc/sudoers.d/90-teknoir-platform-user. A user name can no longer become a sudoers keyword (a platform user 'ALL' would have given every account root), and the file name has no '.', which sudo's includedir skips. Drop-ins of earlier platform users (90-teknoir-) are removed. - tnctl bootstrap now runs ValidateTemplateData before rendering, and the Username must be a lowercase login name (not root). - Group membership falls back to /etc/group when getent is missing. - Write and validation failures leave no drop-in and only warn. - ansible-inventory prints one stderr line per device it leaves out. - docs/commands/ansible.md: the plugin is deprecated; become; synchronize needs use_ssh_args and verify_host (else rsync skips the host key check). Co-Authored-By: Claude Opus 5.5 --- cmd/ansible_inventory.go | 15 ++++++++- cmd/ansible_inventory_test.go | 13 +++++++- cmd/bootstrap.go | 4 +++ docs/commands/ansible.md | 32 ++++++++++++++++-- pkg/templates/partials/user_setup.sh.tmpl | 33 +++++++++++++------ pkg/templates/render.go | 10 ++++++ pkg/templates/render_test.go | 18 ++++++++++ pkg/templates/testdata/agent.golden.sh | 33 +++++++++++++------ .../testdata/docker_server.golden.sh | 33 +++++++++++++------ pkg/templates/testdata/server.golden.sh | 33 +++++++++++++------ 10 files changed, 179 insertions(+), 45 deletions(-) diff --git a/cmd/ansible_inventory.go b/cmd/ansible_inventory.go index 95c30d6..39b261f 100644 --- a/cmd/ansible_inventory.go +++ b/cmd/ansible_inventory.go @@ -125,6 +125,16 @@ func deviceWireGuardActive(d *api.BackstageDevice) bool { return wg.State == "active" } +func unreachableReason(transport string) string { + switch transport { + case transportWireGuard: + return "WireGuard is not active" + case transportTunnel: + return "the SSH tunnel is not open" + } + return "no active WireGuard and no open SSH tunnel" +} + func withBecome(hv map[string]interface{}, password string) map[string]interface{} { hv["ansible_become_user"] = "root" hv["ansible_become_flags"] = "-E" @@ -137,7 +147,8 @@ func withBecome(hv map[string]interface{}, password string) map[string]interface // buildInventory builds the Ansible dynamic inventory (--list form) for the // devices and writes the key files. Devices without login data or without a -// usable transport are listed in their groups but get no host vars. +// usable transport are left out (one line on warn each), so a run reports +// only the devices it can reach. func buildInventory(devices []api.BackstageDevice, o inventoryOpts, warn io.Writer) (map[string]interface{}, error) { if o.Transport == "" { o.Transport = transportAuto @@ -186,6 +197,7 @@ func buildInventory(devices []api.BackstageDevice, o inventoryOpts, warn io.Writ } if d.Spec.Settings.Username == "" || d.Spec.Settings.RSAPrivate == "" { + fmt.Fprintf(warn, "tnctl: device %q has no login data in the platform; left out of the inventory\n", name) continue } username, password, key, err := decodeDeviceLogin(d) @@ -205,6 +217,7 @@ func buildInventory(devices []api.BackstageDevice, o inventoryOpts, warn io.Writ port := d.Spec.Subresources.Status.RemoteAccess.Port tunnelOpen := d.Spec.Subresources.Status.RemoteAccess.Active && port != "" if !useWG && !(o.Transport != transportWireGuard && tunnelOpen) { + fmt.Fprintf(warn, "tnctl: device %q is not reachable (%s); left out of the inventory\n", name, unreachableReason(o.Transport)) continue } diff --git a/cmd/ansible_inventory_test.go b/cmd/ansible_inventory_test.go index 9942103..f3fa51a 100644 --- a/cmd/ansible_inventory_test.go +++ b/cmd/ansible_inventory_test.go @@ -1,7 +1,9 @@ package cmd import ( + "bytes" "encoding/base64" + "fmt" "os" "path/filepath" "reflect" @@ -123,11 +125,20 @@ func TestBuildInventoryTransports(t *testing.T) { } for _, c := range cases { t.Run(c.transport, func(t *testing.T) { - inv, err := buildInventory(devs, testOpts(t, c.transport), os.Stderr) + var warned bytes.Buffer + inv, err := buildInventory(devs, testOpts(t, c.transport), &warned) if err != nil { t.Fatal(err) } hv := hostvars(inv) + // every left-out device is named once on the warning stream + for _, d := range devs { + name := d.Metadata.Name + _, in := c.want[name] + if n := strings.Count(warned.String(), fmt.Sprintf("device %q", name)); (n == 1) == in { + t.Errorf("device %s: %d warning lines, in inventory = %v:\n%s", name, n, in, warned.String()) + } + } if len(hv) != len(c.want) { t.Fatalf("hostvars for %d hosts, want %d: %v", len(hv), len(c.want), hv) } diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 570d6c5..9b85c67 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -134,6 +134,10 @@ var bootstrapCmd = &cobra.Command{ K3SForce: force, } + if err := templates.ValidateTemplateData(data); err != nil { + return fmt.Errorf("device %s: %w", deviceName, err) + } + generate := func(tmpl *template.Template, filename string) error { f, err := os.Create(filename) if err != nil { diff --git a/docs/commands/ansible.md b/docs/commands/ansible.md index e21659c..1d752f8 100644 --- a/docs/commands/ansible.md +++ b/docs/commands/ansible.md @@ -11,7 +11,12 @@ tnctl ansible-inventory --list # the inventory itself (Ansible dynamic-inven tnctl ansible-inventory --host ``` -`ansible` / `ansible-playbook` must be installed and in `PATH`. All arguments are passed through untouched; only +tnctl replaces the Teknoir Ansible inventory plugin (`inventory.py` and `install.sh` in +[teknoir/ansible](https://github.com/teknoir/ansible)), which is deprecated. The playbooks in that repo run through +`tnctl ansible-playbook`. + +`ansible` / `ansible-playbook` must be installed and in `PATH`. With `ansible-core` alone, also install the +collections the Teknoir playbooks use: `ansible-galaxy collection install ansible.posix community.general`. All arguments are passed through untouched; only tnctl's own `-d`, `-n`, `--config` and `--debug` are accepted in front of them. ```bash @@ -29,8 +34,29 @@ Hosts are the devices of the active domain and namespace (`-d`/`-n`, or the save A device needs a login name and a private key in the catalog. The password is optional; it only sets `ansible_become_pass` / `ansible_sudo_pass` (always with `ansible_become_user=root`, `ansible_become_flags=-E`). -A device without login data, or without a usable transport (see below), stays in its groups but gets **no host -variables**, so Ansible reports it as unreachable instead of failing the run. +A device without login data, or without a usable transport (see below), is left out of the inventory and its groups, +with one line on stderr per device (`tnctl: device "x" is not reachable (...)`), so a run only targets devices it can +reach. + +`ansible_teknoir_login_user` holds the platform user, also when you log in as someone else with +`-e ansible_user=...`. + +## Become (sudo) + +`-b` runs tasks as root through sudo. The device bootstrap gives the platform user passwordless sudo when the platform +holds no password for it (key-only login, the default); otherwise the inventory supplies the device password from the +platform. A device bootstrapped before passwordless sudo existed needs one re-bootstrap (see `playbooks/rebootstrap-device.yml` +in teknoir/ansible) or `-e ansible_user=` until then. + +## synchronize (rsync) + +`ansible.posix.synchronize` runs its own `ssh` for rsync. Pass `use_ssh_args: true` so it uses the inventory's transport +(the WireGuard `ProxyCommand` or the tunnel), and `verify_host: true` so it keeps the host key check: without it the +module puts `StrictHostKeyChecking=no` in front of the inventory's options, and ssh uses the first value. + +```bash +tnctl ansible -b -m synchronize -a "src=/var/log/syslog dest=./ mode=pull use_ssh_args=yes verify_host=yes" +``` Private keys are written to `/tnctl/ansible///.pem` (directories `0700`, files `0600`) and rewritten when the catalog key changes. diff --git a/pkg/templates/partials/user_setup.sh.tmpl b/pkg/templates/partials/user_setup.sh.tmpl index fa18e24..aef4cc2 100644 --- a/pkg/templates/partials/user_setup.sh.tmpl +++ b/pkg/templates/partials/user_setup.sh.tmpl @@ -50,7 +50,7 @@ chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" # Add the user to the users and sudo groups (once: a re-run must not list it twice) for _tn_group in users sudo; do - if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if { getent group "\${_tn_group}" > /dev/null 2>&1 || grep -q "^\${_tn_group}:" /etc/group 2> /dev/null; } && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then if command -v usermod > /dev/null 2>&1; then usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" else @@ -66,11 +66,17 @@ EOF # A key-only platform user (no password in the platform) cannot answer sudo's # password prompt, so automation over SSH (Ansible become, tnctl) could never # act as root. Give exactly that user passwordless sudo with a validated -# drop-in. This is no weaker than a password: the SSH key and any password -# live in the same platform record, and the key alone already logs in. With a -# password set, the drop-in is removed again and sudo asks for the password. +# drop-in. With a password set, the drop-in is removed again and sudo asks for +# the password. The rule names the user by uid (#), so no user name can +# turn into a sudoers keyword, and the file name has no '.' (sudo's includedir +# skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { - _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" + for _tn_old in /etc/sudoers.d/90-teknoir-*; do + if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then + $SUDO rm -f "${_tn_old}" + fi + done if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 @@ -79,12 +85,19 @@ setup_user_sudo() { warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" return 0 fi - _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) - echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null - $SUDO chmod 0440 "${_tn_tmp}" - if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + _tn_uid=$(id -u "${_FIRST_USER_NAME}" 2> /dev/null) || _tn_uid="" + case "${_tn_uid}" in + ''|*[!0-9]*|0) + warn "No usable uid for ${_FIRST_USER_NAME}: no passwordless sudo" + return 0 + ;; + esac + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) || { warn "Cannot create a sudoers drop-in: no passwordless sudo for ${_FIRST_USER_NAME}"; return 0; } + if ! echo "#${_tn_uid} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null \ + || ! $SUDO chmod 0440 "${_tn_tmp}" \ + || { command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; }; then $SUDO rm -f "${_tn_tmp}" - warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} could not be written or did not validate; no passwordless sudo" return 0 fi $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" diff --git a/pkg/templates/render.go b/pkg/templates/render.go index 8ab21e8..818dbb2 100644 --- a/pkg/templates/render.go +++ b/pkg/templates/render.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "io/fs" + "regexp" "strings" ) @@ -21,6 +22,12 @@ var allTemplatesFS embed.FS // characters would allow quoting breakouts or expansion. const unsafeChars = "\"`$\\" +// loginNameRe is the device login name the scripts accept: a lowercase POSIX +// login name (Debian's default NAME_REGEX, at most 32 characters). It keeps +// sudoers keywords such as ALL and names with '.' (which sudo's includedir +// skips) out of the sudoers drop-in. +var loginNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) + type field struct { name string value string @@ -54,6 +61,9 @@ func ValidateTemplateData(d TemplateData) error { return fmt.Errorf("template data field %s must not contain a newline", f.name) } } + if d.Username != "" && (!loginNameRe.MatchString(d.Username) || d.Username == "root") { + return fmt.Errorf("template data field Username must be a lowercase login name (a-z, 0-9, _ and -, starting with a letter or _, at most 32 characters, not root)") + } return nil } diff --git a/pkg/templates/render_test.go b/pkg/templates/render_test.go index 76dec55..cf7eeee 100644 --- a/pkg/templates/render_test.go +++ b/pkg/templates/render_test.go @@ -108,6 +108,24 @@ func TestValidateTemplateData_Rejects(t *testing.T) { } } +func TestValidateTemplateData_Username(t *testing.T) { + for _, u := range []string{"teknoir", "golden-user", "_svc", "pi", "a1_b-c"} { + d := goldenData() + d.Username = u + if err := ValidateTemplateData(d); err != nil { + t.Errorf("%q rejected: %v", u, err) + } + } + // ALL would be a sudoers keyword, '.' makes sudo skip the drop-in, root is never the platform user + for _, u := range []string{"ALL", "Teknoir", "first.last", "root", "-x", "1user", "a b", "user~", strings.Repeat("a", 33)} { + d := goldenData() + d.Username = u + if err := ValidateTemplateData(d); err == nil || !strings.Contains(err.Error(), "Username") { + t.Errorf("%q accepted or error does not name the field: %v", u, err) + } + } +} + func TestTemplatesSHA256(t *testing.T) { a, b := TemplatesSHA256(), TemplatesSHA256() if a != b || len(a) != 64 { diff --git a/pkg/templates/testdata/agent.golden.sh b/pkg/templates/testdata/agent.golden.sh index 79b430d..00fc828 100644 --- a/pkg/templates/testdata/agent.golden.sh +++ b/pkg/templates/testdata/agent.golden.sh @@ -864,7 +864,7 @@ chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" # Add the user to the users and sudo groups (once: a re-run must not list it twice) for _tn_group in users sudo; do - if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if { getent group "\${_tn_group}" > /dev/null 2>&1 || grep -q "^\${_tn_group}:" /etc/group 2> /dev/null; } && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then if command -v usermod > /dev/null 2>&1; then usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" else @@ -880,11 +880,17 @@ EOF # A key-only platform user (no password in the platform) cannot answer sudo's # password prompt, so automation over SSH (Ansible become, tnctl) could never # act as root. Give exactly that user passwordless sudo with a validated -# drop-in. This is no weaker than a password: the SSH key and any password -# live in the same platform record, and the key alone already logs in. With a -# password set, the drop-in is removed again and sudo asks for the password. +# drop-in. With a password set, the drop-in is removed again and sudo asks for +# the password. The rule names the user by uid (#), so no user name can +# turn into a sudoers keyword, and the file name has no '.' (sudo's includedir +# skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { - _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" + for _tn_old in /etc/sudoers.d/90-teknoir-*; do + if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then + $SUDO rm -f "${_tn_old}" + fi + done if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 @@ -893,12 +899,19 @@ setup_user_sudo() { warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" return 0 fi - _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) - echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null - $SUDO chmod 0440 "${_tn_tmp}" - if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + _tn_uid=$(id -u "${_FIRST_USER_NAME}" 2> /dev/null) || _tn_uid="" + case "${_tn_uid}" in + ''|*[!0-9]*|0) + warn "No usable uid for ${_FIRST_USER_NAME}: no passwordless sudo" + return 0 + ;; + esac + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) || { warn "Cannot create a sudoers drop-in: no passwordless sudo for ${_FIRST_USER_NAME}"; return 0; } + if ! echo "#${_tn_uid} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null \ + || ! $SUDO chmod 0440 "${_tn_tmp}" \ + || { command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; }; then $SUDO rm -f "${_tn_tmp}" - warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} could not be written or did not validate; no passwordless sudo" return 0 fi $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" diff --git a/pkg/templates/testdata/docker_server.golden.sh b/pkg/templates/testdata/docker_server.golden.sh index d801ad2..16f03a9 100644 --- a/pkg/templates/testdata/docker_server.golden.sh +++ b/pkg/templates/testdata/docker_server.golden.sh @@ -979,7 +979,7 @@ chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" # Add the user to the users and sudo groups (once: a re-run must not list it twice) for _tn_group in users sudo; do - if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if { getent group "\${_tn_group}" > /dev/null 2>&1 || grep -q "^\${_tn_group}:" /etc/group 2> /dev/null; } && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then if command -v usermod > /dev/null 2>&1; then usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" else @@ -995,11 +995,17 @@ EOF # A key-only platform user (no password in the platform) cannot answer sudo's # password prompt, so automation over SSH (Ansible become, tnctl) could never # act as root. Give exactly that user passwordless sudo with a validated -# drop-in. This is no weaker than a password: the SSH key and any password -# live in the same platform record, and the key alone already logs in. With a -# password set, the drop-in is removed again and sudo asks for the password. +# drop-in. With a password set, the drop-in is removed again and sudo asks for +# the password. The rule names the user by uid (#), so no user name can +# turn into a sudoers keyword, and the file name has no '.' (sudo's includedir +# skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { - _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" + for _tn_old in /etc/sudoers.d/90-teknoir-*; do + if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then + $SUDO rm -f "${_tn_old}" + fi + done if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 @@ -1008,12 +1014,19 @@ setup_user_sudo() { warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" return 0 fi - _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) - echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null - $SUDO chmod 0440 "${_tn_tmp}" - if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + _tn_uid=$(id -u "${_FIRST_USER_NAME}" 2> /dev/null) || _tn_uid="" + case "${_tn_uid}" in + ''|*[!0-9]*|0) + warn "No usable uid for ${_FIRST_USER_NAME}: no passwordless sudo" + return 0 + ;; + esac + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) || { warn "Cannot create a sudoers drop-in: no passwordless sudo for ${_FIRST_USER_NAME}"; return 0; } + if ! echo "#${_tn_uid} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null \ + || ! $SUDO chmod 0440 "${_tn_tmp}" \ + || { command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; }; then $SUDO rm -f "${_tn_tmp}" - warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} could not be written or did not validate; no passwordless sudo" return 0 fi $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" diff --git a/pkg/templates/testdata/server.golden.sh b/pkg/templates/testdata/server.golden.sh index 6b2cbb4..b1f495d 100644 --- a/pkg/templates/testdata/server.golden.sh +++ b/pkg/templates/testdata/server.golden.sh @@ -3209,7 +3209,7 @@ chown -R "${_FIRST_USER_NAME}:${_FIRST_USER_NAME}" "/home/${_FIRST_USER_NAME}" # Add the user to the users and sudo groups (once: a re-run must not list it twice) for _tn_group in users sudo; do - if getent group "\${_tn_group}" > /dev/null 2>&1 && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then + if { getent group "\${_tn_group}" > /dev/null 2>&1 || grep -q "^\${_tn_group}:" /etc/group 2> /dev/null; } && ! id -nG "${_FIRST_USER_NAME}" | tr ' ' '\\n' | grep -qx "\${_tn_group}"; then if command -v usermod > /dev/null 2>&1; then usermod -aG "\${_tn_group}" "${_FIRST_USER_NAME}" else @@ -3225,11 +3225,17 @@ EOF # A key-only platform user (no password in the platform) cannot answer sudo's # password prompt, so automation over SSH (Ansible become, tnctl) could never # act as root. Give exactly that user passwordless sudo with a validated -# drop-in. This is no weaker than a password: the SSH key and any password -# live in the same platform record, and the key alone already logs in. With a -# password set, the drop-in is removed again and sudo asks for the password. +# drop-in. With a password set, the drop-in is removed again and sudo asks for +# the password. The rule names the user by uid (#), so no user name can +# turn into a sudoers keyword, and the file name has no '.' (sudo's includedir +# skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { - _tn_sudoers="/etc/sudoers.d/90-teknoir-${_FIRST_USER_NAME}" + _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" + for _tn_old in /etc/sudoers.d/90-teknoir-*; do + if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then + $SUDO rm -f "${_tn_old}" + fi + done if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 @@ -3238,12 +3244,19 @@ setup_user_sudo() { warn "No /etc/sudoers.d: ${_FIRST_USER_NAME} gets no passwordless sudo" return 0 fi - _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) - echo "${_FIRST_USER_NAME} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null - $SUDO chmod 0440 "${_tn_tmp}" - if command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; then + _tn_uid=$(id -u "${_FIRST_USER_NAME}" 2> /dev/null) || _tn_uid="" + case "${_tn_uid}" in + ''|*[!0-9]*|0) + warn "No usable uid for ${_FIRST_USER_NAME}: no passwordless sudo" + return 0 + ;; + esac + _tn_tmp=$($SUDO mktemp /etc/sudoers.d/.teknoir.XXXXXX) || { warn "Cannot create a sudoers drop-in: no passwordless sudo for ${_FIRST_USER_NAME}"; return 0; } + if ! echo "#${_tn_uid} ALL=(ALL:ALL) NOPASSWD: ALL" | $SUDO tee "${_tn_tmp}" > /dev/null \ + || ! $SUDO chmod 0440 "${_tn_tmp}" \ + || { command -v visudo > /dev/null 2>&1 && ! $SUDO visudo -cf "${_tn_tmp}" > /dev/null; }; then $SUDO rm -f "${_tn_tmp}" - warn "The sudoers drop-in for ${_FIRST_USER_NAME} did not validate; no passwordless sudo" + warn "The sudoers drop-in for ${_FIRST_USER_NAME} could not be written or did not validate; no passwordless sudo" return 0 fi $SUDO mv -f "${_tn_tmp}" "${_tn_sudoers}" From 60c1fbb99137e8e91ce645197caa6f839f8dd314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85slund?= Date: Fri, 16 Oct 2026 19:31:01 +0000 Subject: [PATCH 6/6] bootstrap: remove stale sudoers drop-ins with find (the scripts run with noglob) Co-Authored-By: Claude Opus 5.5 --- pkg/templates/partials/user_setup.sh.tmpl | 9 ++++----- pkg/templates/testdata/agent.golden.sh | 9 ++++----- pkg/templates/testdata/docker_server.golden.sh | 9 ++++----- pkg/templates/testdata/server.golden.sh | 9 ++++----- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/pkg/templates/partials/user_setup.sh.tmpl b/pkg/templates/partials/user_setup.sh.tmpl index aef4cc2..2d72e48 100644 --- a/pkg/templates/partials/user_setup.sh.tmpl +++ b/pkg/templates/partials/user_setup.sh.tmpl @@ -72,11 +72,10 @@ EOF # skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" - for _tn_old in /etc/sudoers.d/90-teknoir-*; do - if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then - $SUDO rm -f "${_tn_old}" - fi - done + # find, not a glob: the bootstrap scripts run with noglob + if [ -d /etc/sudoers.d ]; then + $SUDO find /etc/sudoers.d -maxdepth 1 -type f -name '90-teknoir-*' ! -name '90-teknoir-platform-user' -exec rm -f {} \; + fi if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 diff --git a/pkg/templates/testdata/agent.golden.sh b/pkg/templates/testdata/agent.golden.sh index 00fc828..29807b8 100644 --- a/pkg/templates/testdata/agent.golden.sh +++ b/pkg/templates/testdata/agent.golden.sh @@ -886,11 +886,10 @@ EOF # skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" - for _tn_old in /etc/sudoers.d/90-teknoir-*; do - if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then - $SUDO rm -f "${_tn_old}" - fi - done + # find, not a glob: the bootstrap scripts run with noglob + if [ -d /etc/sudoers.d ]; then + $SUDO find /etc/sudoers.d -maxdepth 1 -type f -name '90-teknoir-*' ! -name '90-teknoir-platform-user' -exec rm -f {} \; + fi if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 diff --git a/pkg/templates/testdata/docker_server.golden.sh b/pkg/templates/testdata/docker_server.golden.sh index 16f03a9..4253bc5 100644 --- a/pkg/templates/testdata/docker_server.golden.sh +++ b/pkg/templates/testdata/docker_server.golden.sh @@ -1001,11 +1001,10 @@ EOF # skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" - for _tn_old in /etc/sudoers.d/90-teknoir-*; do - if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then - $SUDO rm -f "${_tn_old}" - fi - done + # find, not a glob: the bootstrap scripts run with noglob + if [ -d /etc/sudoers.d ]; then + $SUDO find /etc/sudoers.d -maxdepth 1 -type f -name '90-teknoir-*' ! -name '90-teknoir-platform-user' -exec rm -f {} \; + fi if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0 diff --git a/pkg/templates/testdata/server.golden.sh b/pkg/templates/testdata/server.golden.sh index b1f495d..979c785 100644 --- a/pkg/templates/testdata/server.golden.sh +++ b/pkg/templates/testdata/server.golden.sh @@ -3231,11 +3231,10 @@ EOF # skips such files). Drop-ins of earlier platform users are removed. setup_user_sudo() { _tn_sudoers="/etc/sudoers.d/90-teknoir-platform-user" - for _tn_old in /etc/sudoers.d/90-teknoir-*; do - if [ -e "${_tn_old}" ] && [ "${_tn_old}" != "${_tn_sudoers}" ]; then - $SUDO rm -f "${_tn_old}" - fi - done + # find, not a glob: the bootstrap scripts run with noglob + if [ -d /etc/sudoers.d ]; then + $SUDO find /etc/sudoers.d -maxdepth 1 -type f -name '90-teknoir-*' ! -name '90-teknoir-platform-user' -exec rm -f {} \; + fi if [ -n "${_FIRST_USER_PASS}" ]; then $SUDO rm -f "${_tn_sudoers}" return 0