Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,54 @@ bt cloud-config.yaml > butane.yaml
is either converted completely or rejected; unsupported fields are never
silently discarded.

| cloud-config field | Butane field |
| ----------------------- | --------------------- |
| `name` | `name` |
| `passwd` | `password_hash` |
| `gecos` | `gecos` |
| `homedir` | `home_dir` |
| `shell` | `shell` |
| `ssh_authorized_keys` | `ssh_authorized_keys` |

Password hashes remain locked, matching cloud-init's default
`lock_passwd: true`. Supplementary and primary groups, sudo configuration,
password unlocking, and every non-`users` stanza are not yet supported.
Jinja templates are not evaluated and are rejected; render them before passing
the resulting cloud-config to `bt`.
| cloud-config stanza | Status | Butane output |
| ------------------- | ------ | ------------- |
| `users` | Supported Cluster API subset | `passwd`, plus generated `storage.files` |
| `write_files` | Unsupported | Planned `storage.files` |
| `runcmd` | Unsupported | Planned scripts and systemd units |
| `bootcmd` | Unsupported | Planned scripts and systemd units |
| `ntp` | Unsupported | Planned time-sync configuration |
| `disk_setup` | Unsupported | Planned storage configuration |
| `fs_setup` | Unsupported | Planned `storage.filesystems` |
| `mounts` | Unsupported | Planned filesystems or systemd mount units |

| cloud-config field | Butane output |
| --------------------- | ------------- |
| `name` | `passwd.users[].name` |
| `passwd` | `passwd.users[].password_hash` |
| `gecos` | `passwd.users[].gecos` |
| `homedir` | `passwd.users[].home_dir` |
| `shell` | `passwd.users[].shell` |
| `ssh_authorized_keys` | `passwd.users[].ssh_authorized_keys` |
| `groups` | `passwd.users[].groups` and synthesized `passwd.groups` |
| `primary_group` | `passwd.users[].primary_group` and synthesized `passwd.groups` |
| `inactive` | `false` is a no-op; `true` is rejected |
| `lock_passwd` | Password locking and a generated sshd drop-in when `false` |
| `sudo` | A generated `/etc/sudoers.d/<username>` file |

The new fields accept only the shapes emitted by Cluster API: `groups` is a
comma-separated string, `primary_group` and `sudo` are non-empty strings, and
`inactive` and `lock_passwd` are booleans. Referenced groups are created before
users. Empty or duplicate supplementary groups are rejected, and a primary
group must not also be listed as a supplementary group. If a referenced group
has the same name as a configured user, that user must set `primary_group`
explicitly to avoid colliding with Linux's implicit private-group creation.

Password hashes are locked when `lock_passwd` is omitted or `true`. When it is
`false`, the hash remains unlocked and `bt` enables SSH password authentication
for that user with a Flatcar sshd drop-in. An already-locked hash is rejected
when `lock_passwd` is `false`. Users that request password login or sudo must
have names matching `^[a-z_][a-z0-9_-]*$` so names cannot become SSH, sudoers,
or path syntax.

Generated sshd and sudoers files are root-owned, mode 0600, and replace any
existing file at the generated path. Sudo rules are preserved verbatim and are
not checked for valid sudoers syntax. No sshd restart is needed because
Ignition writes the drop-in before sshd starts.

Every non-`users` stanza remains unsupported. Jinja templates are not evaluated
and are rejected; render them before passing the resulting cloud-config to
`bt`.

### A Flatcar Container Linux project

Expand Down
6 changes: 3 additions & 3 deletions testcases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ boundary.

| Fixture | Purpose | Current result |
| --- | --- | --- |
| `cluster-api-supported-user.yaml` | Every currently supported user field | Success |
| `cluster-api-groups.yaml` | Cluster API's comma-separated groups and primary group | Rejected until group support lands |
| `cluster-api-deferred-fields.yaml` | Remaining rendered account-policy fields | Rejected as unsupported |
| `cluster-api-supported-user.yaml` | Cluster API's direct user-field mappings | Success |
| `cluster-api-groups.yaml` | Same group used as supplementary and primary | Rejected as contradictory |
| `cluster-api-deferred-fields.yaml` | Cluster API account-policy value shapes | Rejected because `inactive: true` cannot be represented |
2 changes: 1 addition & 1 deletion testcases/cluster-api-groups.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#cloud-config
# Cluster API renders additional groups as one comma-separated scalar.
# This deliberately repeats the primary group as a supplementary group.
users:
- name: foo
groups: "foo, bar"
Expand Down
6 changes: 3 additions & 3 deletions transpile.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ func Transpile(input []byte) ([]byte, error) {
}

config := schema.Config{Variant: variant, Version: version}
if len(users) > 0 {
config.Passwd.Users = users
}
config.Passwd.Groups = users.Groups
config.Passwd.Users = users.Users
config.Storage.Files = users.Files
Comment thread
navaneeth-dev marked this conversation as resolved.
out, err := yaml.MarshalWithOptions(config, yaml.OmitZero(), yaml.Indent(2), yaml.IndentSequence(true))
if err != nil {
return nil, fmt.Errorf("encode Butane config: %w", err)
Expand Down
188 changes: 170 additions & 18 deletions users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,48 @@ package transpile

import (
"fmt"
"regexp"
"strings"

base "github.com/coreos/ignition/v2/butane/base/v0_5"
"github.com/goccy/go-yaml/ast"
)

func parseUsers(document map[string]any, file *ast.File) ([]base.PasswdUser, ValidationErrors) {
var generatedConfigUsername = regexp.MustCompile(`^[a-z_][a-z0-9_-]*$`)

type usersConfig struct {
Files []base.File
Groups []base.PasswdGroup
Users []base.PasswdUser
}

type parsedUser struct {
PasswordLogin bool
Sudo *string
User base.PasswdUser
}

type userDeclaration struct {
HasPrimaryGroup bool
Path string
}

func parseUsers(document map[string]any, file *ast.File) (usersConfig, ValidationErrors) {
var config usersConfig
var problems ValidationErrors
rawUsers, exists := document["users"]
if !exists {
return nil, problems
return config, problems
}
items, ok := rawUsers.([]any)
if !ok {
problems = append(problems, problem(file, "users", "must be a list"))
return nil, problems
return config, problems
}

users := make([]base.PasswdUser, 0, len(items))
seenUsers := map[string]struct{}{}
seenGroups := map[string]struct{}{}
declaredUsers := map[string]userDeclaration{}
var passwordLoginUsers []string
for i, item := range items {
path := fmt.Sprintf("users[%d]", i)
fields, ok := item.(map[string]any)
Expand All @@ -30,24 +52,61 @@ func parseUsers(document map[string]any, file *ast.File) ([]base.PasswdUser, Val
continue
}

user, userProblems := parseUser(fields, path, file)
parsed, userProblems := parseUser(fields, path, file)
problems = append(problems, userProblems...)
user := parsed.User
if user.Name != "" {
if _, duplicate := seenUsers[user.Name]; duplicate {
if _, duplicate := declaredUsers[user.Name]; duplicate {
problems = append(problems, problem(file, path+".name", fmt.Sprintf("duplicate user %q", user.Name)))
} else {
seenUsers[user.Name] = struct{}{}
declaredUsers[user.Name] = userDeclaration{
HasPrimaryGroup: user.PrimaryGroup != nil,
Path: path,
}
}
}
users = append(users, user)
for _, group := range user.Groups {
config.Groups = appendGroup(config.Groups, seenGroups, string(group))
}
if user.PrimaryGroup != nil {
config.Groups = appendGroup(config.Groups, seenGroups, *user.PrimaryGroup)
}
if parsed.PasswordLogin {
passwordLoginUsers = append(passwordLoginUsers, user.Name)
}
if parsed.Sudo != nil && user.Name != "" {
contents := fmt.Sprintf("%s %s\n", user.Name, *parsed.Sudo)
config.Files = append(config.Files, generatedFile("/etc/sudoers.d/"+user.Name, contents))
}
config.Users = append(config.Users, user)
}
for _, group := range config.Groups {
user, matchesUser := declaredUsers[group.Name]
if matchesUser && !user.HasPrimaryGroup {
message := fmt.Sprintf("is required because group %q is explicitly referenced", group.Name)
problems = append(problems, problem(file, user.Path+".primary_group", message))
}
}
if len(passwordLoginUsers) > 0 {
contents := fmt.Sprintf("Match User %s\n PasswordAuthentication yes\nMatch all\n", strings.Join(passwordLoginUsers, ","))
config.Files = append(config.Files, generatedFile("/etc/ssh/sshd_config.d/20-butane-init-password-auth.conf", contents))
}
return users, problems
return config, problems
}

func parseUser(fields map[string]any, path string, file *ast.File) (base.PasswdUser, ValidationErrors) {
func parseUser(fields map[string]any, path string, file *ast.File) (parsedUser, ValidationErrors) {
allowed := map[string]bool{
"name": true, "passwd": true, "gecos": true, "homedir": true,
"shell": true, "ssh_authorized_keys": true,
"name": true,
"passwd": true,
"gecos": true,
"homedir": true,
"shell": true,
"ssh_authorized_keys": true,
"groups": true,
"primary_group": true,
"inactive": true,
"lock_passwd": true,
"sudo": true,
}
var problems ValidationErrors
for _, key := range sortedKeys(fields) {
Expand All @@ -56,17 +115,93 @@ func parseUser(fields map[string]any, path string, file *ast.File) (base.PasswdU
}
}

var user base.PasswdUser
var result parsedUser
user := &result.User
user.Name = requiredString(fields, "name", path, file, &problems)
user.Gecos = optionalString(fields, "gecos", path, file, &problems)
user.Groups = supplementaryGroups(fields, path, file, &problems)
user.HomeDir = optionalString(fields, "homedir", path, file, &problems)
user.PrimaryGroup = optionalString(fields, "primary_group", path, file, &problems)
user.Shell = optionalString(fields, "shell", path, file, &problems)
result.Sudo = optionalString(fields, "sudo", path, file, &problems)
if inactive := optionalBool(fields, "inactive", path, file, &problems); inactive != nil && *inactive {
problems = append(problems, problem(file, path+".inactive", "true is unsupported"))
}
if user.PrimaryGroup != nil && containsGroup(user.Groups, *user.PrimaryGroup) {
problems = append(problems, problem(file, path+".primary_group", "must not also be a supplementary group"))
}
if lockPasswd := optionalBool(fields, "lock_passwd", path, file, &problems); lockPasswd != nil {
result.PasswordLogin = !*lockPasswd
}
if passwd := optionalString(fields, "passwd", path, file, &problems); passwd != nil {
locked := lockPassword(*passwd)
user.PasswordHash = &locked
if result.PasswordLogin {
if passwordLocked(*passwd) {
problems = append(problems, problem(file, path+".passwd", "must not be locked when lock_passwd is false"))
} else {
user.PasswordHash = passwd
}
} else {
locked := lockPassword(*passwd)
user.PasswordHash = &locked
}
}
user.SSHAuthorizedKeys = sshKeys(fields, path, file, &problems)
return user, problems
if (result.PasswordLogin || result.Sudo != nil) && user.Name != "" && !generatedConfigUsername.MatchString(user.Name) {
problems = append(problems, problem(file, path+".name", "is unsafe for generated configuration"))
}
return result, problems
}

func generatedFile(path, contents string) base.File {
mode := 0o600
overwrite := true
return base.File{
Overwrite: &overwrite,
Path: path,
Contents: base.Resource{Inline: &contents},
Mode: &mode,
}
}

func supplementaryGroups(fields map[string]any, path string, file *ast.File, problems *ValidationErrors) []base.Group {
raw := optionalString(fields, "groups", path, file, problems)
if raw == nil {
return nil
}

var groups []base.Group
seen := map[string]struct{}{}
for _, item := range strings.Split(*raw, ",") {
name := strings.TrimSpace(item)
if name == "" {
*problems = append(*problems, problem(file, path+".groups", "must not contain an empty group name"))
continue
}
if _, duplicate := seen[name]; duplicate {
*problems = append(*problems, problem(file, path+".groups", fmt.Sprintf("duplicate group %q", name)))
continue
}
seen[name] = struct{}{}
groups = append(groups, base.Group(name))
}
return groups
}

func containsGroup(groups []base.Group, name string) bool {
for _, group := range groups {
if string(group) == name {
return true
}
}
return false
}

func appendGroup(groups []base.PasswdGroup, seen map[string]struct{}, name string) []base.PasswdGroup {
if _, exists := seen[name]; exists {
return groups
}
seen[name] = struct{}{}
return append(groups, base.PasswdGroup{Name: name})
}

func requiredString(fields map[string]any, key, path string, file *ast.File, problems *ValidationErrors) string {
Expand Down Expand Up @@ -97,6 +232,19 @@ func optionalString(fields map[string]any, key, path string, file *ast.File, pro
return &value
}

func optionalBool(fields map[string]any, key, path string, file *ast.File, problems *ValidationErrors) *bool {
raw, exists := fields[key]
if !exists {
return nil
}
value, ok := raw.(bool)
if !ok {
*problems = append(*problems, problem(file, path+"."+key, "must be a boolean"))
return nil
}
return &value
}

func sshKeys(fields map[string]any, path string, file *ast.File, problems *ValidationErrors) []base.SSHAuthorizedKey {
raw, exists := fields["ssh_authorized_keys"]
if !exists {
Expand Down Expand Up @@ -132,8 +280,12 @@ func sshKeys(fields map[string]any, path string, file *ast.File, problems *Valid
}

func lockPassword(hash string) string {
if strings.HasPrefix(hash, "!") || strings.HasPrefix(hash, "*") {
if passwordLocked(hash) {
return hash
}
return "!" + hash
}

func passwordLocked(hash string) bool {
return strings.HasPrefix(hash, "!") || strings.HasPrefix(hash, "*")
}
Loading
Loading