2026-04-24 16:57:34 -03:00
|
|
|
package plan
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"metis/pkg/config"
|
|
|
|
|
"metis/pkg/secrets"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func cloudInitUserData(cfg *config.NodeConfig, sec *secrets.NodeSecrets) string {
|
|
|
|
|
if cfg == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if sec != nil && sec.CloudInit != "" {
|
|
|
|
|
return sec.CloudInit
|
|
|
|
|
}
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
b.WriteString("#cloud-config\n")
|
|
|
|
|
b.WriteString(fmt.Sprintf("hostname: %s\n", cfg.Hostname))
|
|
|
|
|
if len(cfg.SSHKeys) > 0 {
|
|
|
|
|
b.WriteString("ssh_authorized_keys:\n")
|
|
|
|
|
for _, k := range cfg.SSHKeys {
|
|
|
|
|
b.WriteString(fmt.Sprintf(" - %s\n", k))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if hasNodePasswords(sec) {
|
|
|
|
|
b.WriteString("ssh_pwauth: true\n")
|
|
|
|
|
b.WriteString("disable_root: false\n")
|
|
|
|
|
}
|
|
|
|
|
b.WriteString("runcmd:\n")
|
|
|
|
|
b.WriteString(" - [bash, -lc, \"/usr/local/sbin/metis-apply-node-identity.sh\"]\n")
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func firstbootEnvContent(cfg *config.NodeConfig, sec *secrets.NodeSecrets) string {
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
b.WriteString(fmt.Sprintf("METIS_HOSTNAME=%s\n", shellQuote(cfg.Hostname)))
|
|
|
|
|
b.WriteString(fmt.Sprintf("METIS_SSH_USER=%s\n", shellQuote(cfg.SSHUser)))
|
|
|
|
|
b.WriteString("METIS_ATLAS_USER='atlas'\n")
|
|
|
|
|
b.WriteString(fmt.Sprintf("METIS_K3S_VERSION=%s\n", shellQuote(cfg.K3s.Version)))
|
|
|
|
|
if sec != nil {
|
|
|
|
|
if value := effectiveAtlasPassword(sec); value != "" {
|
|
|
|
|
b.WriteString(fmt.Sprintf("METIS_ATLAS_PASSWORD=%s\n", shellQuote(value)))
|
|
|
|
|
}
|
|
|
|
|
if value := strings.TrimSpace(sec.RootPassword); value != "" {
|
|
|
|
|
b.WriteString(fmt.Sprintf("METIS_ROOT_PASSWORD=%s\n", shellQuote(value)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func cloudInitRootFSContent(sec *secrets.NodeSecrets) string {
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
b.WriteString("#cloud-config\n")
|
|
|
|
|
if hasNodePasswords(sec) {
|
|
|
|
|
b.WriteString("ssh_pwauth: true\n")
|
|
|
|
|
b.WriteString("disable_root: false\n")
|
|
|
|
|
}
|
|
|
|
|
b.WriteString("runcmd:\n")
|
|
|
|
|
b.WriteString(" - [bash, -lc, \"/usr/local/sbin/metis-apply-node-identity.sh\"]\n")
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func nodeIdentityScriptContent() string {
|
|
|
|
|
return `#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
marker="/var/lib/metis/node-identity-applied.done"
|
|
|
|
|
env_file="/etc/metis/firstboot.env"
|
2026-10-04 00:27:55 -05:00
|
|
|
pending="/etc/metis/node-identity.pending"
|
2026-04-24 16:57:34 -03:00
|
|
|
key_file="/etc/metis/authorized_keys"
|
|
|
|
|
default_groups=(adm sudo tty disk dialout audio video plugdev games users systemd-journal input render netdev)
|
|
|
|
|
|
2026-10-04 00:27:55 -05:00
|
|
|
if [ -f "${marker}" ] && [ ! -f "${pending}" ]; then
|
2026-04-24 16:57:34 -03:00
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
mkdir -p /var/lib/metis
|
2026-10-04 00:27:55 -05:00
|
|
|
umask 077
|
|
|
|
|
# Cloud-init and native first boot can arrive together. Only one applies identity.
|
|
|
|
|
exec 9>/var/lib/metis/node-identity.lock
|
|
|
|
|
flock -x 9
|
|
|
|
|
if [ -f "${marker}" ] && [ ! -f "${pending}" ]; then exit 0; fi
|
2026-04-24 16:57:34 -03:00
|
|
|
if [ -f "${env_file}" ]; then
|
|
|
|
|
# shellcheck disable=SC1090
|
|
|
|
|
. "${env_file}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
atlas_user="${METIS_ATLAS_USER:-atlas}"
|
|
|
|
|
ssh_user="${METIS_SSH_USER:-${atlas_user}}"
|
|
|
|
|
atlas_password="${METIS_ATLAS_PASSWORD:-}"
|
|
|
|
|
root_password="${METIS_ROOT_PASSWORD:-}"
|
2026-10-04 00:27:55 -05:00
|
|
|
if [ -z "${atlas_password}" ] || [ -z "${root_password}" ]; then
|
|
|
|
|
echo "Metis identity requires both administrator passwords; no completion marker written" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
case "${atlas_password}${root_password}" in
|
|
|
|
|
*$'\n'*|*$'\r'*) echo "Invalid password record" >&2; exit 1 ;;
|
|
|
|
|
esac
|
2026-04-24 16:57:34 -03:00
|
|
|
|
|
|
|
|
group_list=()
|
|
|
|
|
for group_name in "${default_groups[@]}"; do
|
|
|
|
|
if getent group "${group_name}" >/dev/null 2>&1; then
|
|
|
|
|
group_list+=("${group_name}")
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
if [ "${#group_list[@]}" -gt 0 ]; then
|
|
|
|
|
group_csv="$(IFS=,; printf '%s' "${group_list[*]}")"
|
|
|
|
|
else
|
|
|
|
|
group_csv=""
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
ensure_user() {
|
|
|
|
|
local user_name="$1"
|
|
|
|
|
[ -n "${user_name}" ] || return 0
|
|
|
|
|
if ! id "${user_name}" >/dev/null 2>&1; then
|
|
|
|
|
if [ -n "${group_csv}" ]; then
|
|
|
|
|
useradd -m -s /bin/bash -G "${group_csv}" "${user_name}"
|
|
|
|
|
else
|
|
|
|
|
useradd -m -s /bin/bash "${user_name}"
|
|
|
|
|
fi
|
|
|
|
|
elif [ -n "${group_csv}" ]; then
|
2026-10-04 00:27:55 -05:00
|
|
|
usermod -a -G "${group_csv}" "${user_name}"
|
2026-04-24 16:57:34 -03:00
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
apply_password() {
|
|
|
|
|
local user_name="$1"
|
|
|
|
|
local plain_password="$2"
|
|
|
|
|
if ! id "${user_name}" >/dev/null 2>&1; then
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
if [ -n "${plain_password}" ]; then
|
|
|
|
|
printf '%s:%s\n' "${user_name}" "${plain_password}" | chpasswd
|
|
|
|
|
passwd -u "${user_name}" >/dev/null 2>&1 || true
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
install_keys() {
|
|
|
|
|
local user_name="$1"
|
|
|
|
|
[ -n "${user_name}" ] || return 0
|
|
|
|
|
[ -s "${key_file}" ] || return 0
|
|
|
|
|
local home_dir
|
|
|
|
|
home_dir="$(getent passwd "${user_name}" | cut -d: -f6)"
|
|
|
|
|
if [ -z "${home_dir}" ]; then
|
|
|
|
|
if [ "${user_name}" = "root" ]; then
|
|
|
|
|
home_dir="/root"
|
|
|
|
|
else
|
|
|
|
|
home_dir="/home/${user_name}"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
install -d -m 700 "${home_dir}/.ssh"
|
|
|
|
|
install -m 600 "${key_file}" "${home_dir}/.ssh/authorized_keys"
|
|
|
|
|
chown -R "${user_name}:${user_name}" "${home_dir}/.ssh" 2>/dev/null || true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensure_user "${atlas_user}"
|
|
|
|
|
if [ -n "${ssh_user}" ] && [ "${ssh_user}" != "root" ] && [ "${ssh_user}" != "${atlas_user}" ]; then
|
|
|
|
|
ensure_user "${ssh_user}"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-04-24 18:07:14 -03:00
|
|
|
apply_password root "${root_password}"
|
|
|
|
|
apply_password "${atlas_user}" "${atlas_password}"
|
2026-04-24 16:57:34 -03:00
|
|
|
|
|
|
|
|
if [ -s "${key_file}" ]; then
|
|
|
|
|
install_keys root
|
|
|
|
|
install_keys "${atlas_user}"
|
|
|
|
|
if [ -n "${ssh_user}" ] && [ "${ssh_user}" != "root" ] && [ "${ssh_user}" != "${atlas_user}" ]; then
|
|
|
|
|
install_keys "${ssh_user}"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-10-04 00:27:55 -05:00
|
|
|
# Remove only the obsolete Metis-owned passwordless command grants.
|
|
|
|
|
rm -f /etc/sudoers.d/90-hecate-atlas /etc/metis/sudoers-hecate
|
|
|
|
|
visudo -c >/dev/null
|
|
|
|
|
# Keep boot metadata needed by other first-boot helpers, without passwords.
|
|
|
|
|
clean_env="$(mktemp /etc/metis/firstboot.env.XXXXXX)"
|
|
|
|
|
for variable in METIS_HOSTNAME METIS_SSH_USER METIS_ATLAS_USER METIS_K3S_VERSION; do
|
|
|
|
|
printf '%s=%q\n' "${variable}" "${!variable-}" >> "${clean_env}"
|
|
|
|
|
done
|
|
|
|
|
chmod 600 "${clean_env}"
|
|
|
|
|
mv "${clean_env}" "${env_file}"
|
|
|
|
|
unset atlas_password root_password METIS_ATLAS_PASSWORD METIS_ROOT_PASSWORD
|
2026-04-24 16:57:34 -03:00
|
|
|
touch "${marker}"
|
2026-10-04 00:27:55 -05:00
|
|
|
rm -f "${pending}"
|
2026-04-24 16:57:34 -03:00
|
|
|
`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sshPasswordConfigContent(sec *secrets.NodeSecrets) string {
|
|
|
|
|
if !hasNodePasswords(sec) {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return "PasswordAuthentication yes\nKbdInteractiveAuthentication no\nChallengeResponseAuthentication no\nPermitRootLogin yes\nUsePAM yes\n"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hasNodePasswords(sec *secrets.NodeSecrets) bool {
|
|
|
|
|
if sec == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-04-24 18:07:14 -03:00
|
|
|
return effectiveAtlasPassword(sec) != "" || strings.TrimSpace(sec.RootPassword) != ""
|
2026-04-24 16:57:34 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func effectiveAtlasPassword(sec *secrets.NodeSecrets) string {
|
|
|
|
|
if sec == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-04-24 18:07:14 -03:00
|
|
|
return firstNonEmptyString(sec.AtlasPassword)
|
2026-04-24 16:57:34 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
|
|
|
return trimmed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func redactedSecretsForImage(sec *secrets.NodeSecrets) map[string]any {
|
|
|
|
|
if sec == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
debug := map[string]any{
|
2026-04-24 18:07:14 -03:00
|
|
|
"has_atlas_password": strings.TrimSpace(sec.AtlasPassword) != "",
|
|
|
|
|
"has_root_password": strings.TrimSpace(sec.RootPassword) != "",
|
2026-04-24 16:57:34 -03:00
|
|
|
"has_k3s_token": strings.TrimSpace(sec.K3sToken) != "",
|
|
|
|
|
"has_cloud_init_override": strings.TrimSpace(sec.CloudInit) != "",
|
|
|
|
|
}
|
|
|
|
|
if len(sec.Extra) > 0 {
|
|
|
|
|
keys := make([]string, 0, len(sec.Extra))
|
|
|
|
|
for key := range sec.Extra {
|
|
|
|
|
key = strings.TrimSpace(key)
|
|
|
|
|
if key == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
keys = append(keys, key)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
debug["extra_keys"] = keys
|
|
|
|
|
}
|
|
|
|
|
return debug
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func shellQuote(value string) string {
|
|
|
|
|
if value == "" {
|
|
|
|
|
return "''"
|
|
|
|
|
}
|
|
|
|
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
|
|
|
|
}
|
2026-10-04 00:27:55 -05:00
|
|
|
|
|
|
|
|
// nodeIdentityUnitContent makes password setup independent of cloud-init support.
|
|
|
|
|
func nodeIdentityUnitContent() string {
|
|
|
|
|
return `[Unit]
|
|
|
|
|
Description=Apply Metis node identity once
|
|
|
|
|
After=local-fs.target cloud-config.service
|
|
|
|
|
Before=k3s-agent.service
|
|
|
|
|
ConditionPathExists=/etc/metis/firstboot.env
|
|
|
|
|
ConditionPathExists=/etc/metis/node-identity.pending
|
|
|
|
|
|
|
|
|
|
[Service]
|
|
|
|
|
Type=oneshot
|
|
|
|
|
UMask=0077
|
|
|
|
|
ExecStart=/usr/local/sbin/metis-apply-node-identity.sh
|
|
|
|
|
RemainAfterExit=yes
|
|
|
|
|
TimeoutStartSec=120
|
|
|
|
|
|
|
|
|
|
[Install]
|
|
|
|
|
WantedBy=multi-user.target
|
|
|
|
|
`
|
|
|
|
|
}
|