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" key_file="/etc/metis/authorized_keys" sudoers_file="/etc/metis/sudoers-hecate" default_groups=(adm sudo tty disk dialout audio video plugdev games users systemd-journal input render netdev) if [ -f "${marker}" ]; then exit 0 fi mkdir -p /var/lib/metis 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:-}" 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 usermod -a -G "${group_csv}" "${user_name}" || true 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 apply_password root "${root_password}" apply_password "${atlas_user}" "${atlas_password}" 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 if [ -s "${sudoers_file}" ]; then install -d -m 755 /etc/sudoers.d install -m 440 "${sudoers_file}" /etc/sudoers.d/90-hecate-atlas if command -v visudo >/dev/null 2>&1; then visudo -cf /etc/sudoers.d/90-hecate-atlas >/dev/null 2>&1 || rm -f /etc/sudoers.d/90-hecate-atlas fi fi systemctl restart ssh.service >/dev/null 2>&1 || systemctl restart sshd.service >/dev/null 2>&1 || systemctl restart ssh.socket >/dev/null 2>&1 || true touch "${marker}" ` } 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 } return effectiveAtlasPassword(sec) != "" || strings.TrimSpace(sec.RootPassword) != "" } func effectiveAtlasPassword(sec *secrets.NodeSecrets) string { if sec == nil { return "" } return firstNonEmptyString(sec.AtlasPassword) } 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{ "has_atlas_password": strings.TrimSpace(sec.AtlasPassword) != "", "has_root_password": strings.TrimSpace(sec.RootPassword) != "", "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, "'", `'"'"'`) + "'" }