Skip to content

Commit 4f5f710

Browse files
committed
base/v0_5_exp: add local file embedding for SSH keys
1 parent 5835eeb commit 4f5f710

10 files changed

Lines changed: 276 additions & 26 deletions

File tree

base/v0_5_exp/schema.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -168,20 +168,21 @@ type PasswdGroup struct {
168168
}
169169

170170
type PasswdUser struct {
171-
Gecos *string `yaml:"gecos"`
172-
Groups []Group `yaml:"groups"`
173-
HomeDir *string `yaml:"home_dir"`
174-
Name string `yaml:"name"`
175-
NoCreateHome *bool `yaml:"no_create_home"`
176-
NoLogInit *bool `yaml:"no_log_init"`
177-
NoUserGroup *bool `yaml:"no_user_group"`
178-
PasswordHash *string `yaml:"password_hash"`
179-
PrimaryGroup *string `yaml:"primary_group"`
180-
ShouldExist *bool `yaml:"should_exist"`
181-
SSHAuthorizedKeys []SSHAuthorizedKey `yaml:"ssh_authorized_keys"`
182-
Shell *string `yaml:"shell"`
183-
System *bool `yaml:"system"`
184-
UID *int `yaml:"uid"`
171+
Gecos *string `yaml:"gecos"`
172+
Groups []Group `yaml:"groups"`
173+
HomeDir *string `yaml:"home_dir"`
174+
Name string `yaml:"name"`
175+
NoCreateHome *bool `yaml:"no_create_home"`
176+
NoLogInit *bool `yaml:"no_log_init"`
177+
NoUserGroup *bool `yaml:"no_user_group"`
178+
PasswordHash *string `yaml:"password_hash"`
179+
PrimaryGroup *string `yaml:"primary_group"`
180+
ShouldExist *bool `yaml:"should_exist"`
181+
SSHAuthorizedKeys []SSHAuthorizedKey `yaml:"ssh_authorized_keys"`
182+
SSHAuthorizedKeysLocal []string `yaml:"ssh_authorized_keys_local"`
183+
Shell *string `yaml:"shell"`
184+
System *bool `yaml:"system"`
185+
UID *int `yaml:"uid"`
185186
}
186187

187188
type Proxy struct {

base/v0_5_exp/translate.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ func (c Config) ToIgn3_4Unvalidated(options common.TranslateOptions) (types.Conf
9191
tr.AddCustomTranslator(translateDirectory)
9292
tr.AddCustomTranslator(translateLink)
9393
tr.AddCustomTranslator(translateResource)
94+
tr.AddCustomTranslator(translatePasswdUser)
9495

9596
tm, r := translate.Prefixed(tr, "ignition", &c.Ignition, &ret.Ignition)
9697
tm.AddTranslation(path.New("yaml", "version"), path.New("json", "ignition", "version"))
@@ -217,6 +218,80 @@ func translateLink(from Link, options common.TranslateOptions) (to types.Link, t
217218
return
218219
}
219220

221+
func translatePasswdUser(from PasswdUser, options common.TranslateOptions) (to types.PasswdUser, tm translate.TranslationSet, r report.Report) {
222+
tr := translate.NewTranslator("yaml", "json", options)
223+
tm, r = translate.Prefixed(tr, "gecos", &from.Gecos, &to.Gecos)
224+
translate.MergeP(tr, tm, &r, "groups", &from.Groups, &to.Groups)
225+
translate.MergeP2(tr, tm, &r, "home_dir", &from.HomeDir, "homeDir", &to.HomeDir)
226+
translate.MergeP(tr, tm, &r, "name", &from.Name, &to.Name)
227+
translate.MergeP2(tr, tm, &r, "no_create_home", &from.NoCreateHome, "noCreateHome", &to.NoCreateHome)
228+
translate.MergeP2(tr, tm, &r, "no_log_init", &from.NoLogInit, "noLogInit", &to.NoLogInit)
229+
translate.MergeP2(tr, tm, &r, "no_user_group", &from.NoUserGroup, "noUserGroup", &to.NoUserGroup)
230+
translate.MergeP2(tr, tm, &r, "password_hash", &from.PasswordHash, "passwordHash", &to.PasswordHash)
231+
translate.MergeP2(tr, tm, &r, "primary_group", &from.PrimaryGroup, "primaryGroup", &to.PrimaryGroup)
232+
translate.MergeP2(tr, tm, &r, "should_exist", &from.ShouldExist, "shouldExist", &to.ShouldExist)
233+
translate.MergeP(tr, tm, &r, "shell", &from.Shell, &to.Shell)
234+
translate.MergeP(tr, tm, &r, "system", &from.System, &to.System)
235+
translate.MergeP(tr, tm, &r, "uid", &from.UID, &to.UID)
236+
237+
var c path.ContextPath
238+
if from.SSHAuthorizedKeys != nil && len(from.SSHAuthorizedKeys) > 0 {
239+
c = path.New("yaml", "ssh_authorized_keys")
240+
tm.AddTranslation(c, path.New("json", "sshAuthorizedKeys"))
241+
for i, sshKey := range from.SSHAuthorizedKeys {
242+
sshKey = SSHAuthorizedKey(strings.TrimSpace(string(sshKey)))
243+
if len(sshKey) == 0 {
244+
r.AddOnError(c, common.ErrSSHKeyEmpty)
245+
}
246+
tm.AddTranslation(c, path.New("json", fmt.Sprintf("sshAuthorizedKeys.%d", i)))
247+
to.SSHAuthorizedKeys = append(to.SSHAuthorizedKeys, types.SSHAuthorizedKey(sshKey))
248+
}
249+
}
250+
251+
if from.SSHAuthorizedKeysLocal != nil && len(from.SSHAuthorizedKeysLocal) > 0 {
252+
c = path.New("yaml", "ssh_authorized_keys_local")
253+
tm.AddTranslation(c, path.New("json", "sshAuthorizedKeys"))
254+
for _, sshKeyFile := range from.SSHAuthorizedKeysLocal {
255+
if options.FilesDir == "" {
256+
r.AddOnError(c, common.ErrNoFilesDir)
257+
return
258+
}
259+
260+
// calculate file path within FilesDir and check for
261+
// path traversal
262+
filePath := filepath.Join(options.FilesDir, sshKeyFile)
263+
if err := baseutil.EnsurePathWithinFilesDir(filePath, options.FilesDir); err != nil {
264+
r.AddOnError(c, err)
265+
continue
266+
}
267+
contents, err := ioutil.ReadFile(filePath)
268+
if err != nil {
269+
r.AddOnError(c, err)
270+
continue
271+
}
272+
273+
sshKeyStrings := strings.TrimSpace(string(contents))
274+
if len(contents) == 0 || len(sshKeyStrings) == 0 {
275+
r.AddOnError(c, common.ErrFileEmpty)
276+
continue
277+
}
278+
// offset for TranslationSets when both ssh_authorized_keys and ssh_authorized_keys_local are available
279+
offset := len(to.SSHAuthorizedKeys)
280+
for i, line := range strings.Split(sshKeyStrings, "\n") {
281+
sshKey := strings.TrimSpace(line)
282+
if len(sshKey) == 0 {
283+
r.AddOnError(c, common.ErrSSHKeyEmpty)
284+
continue
285+
}
286+
tm.AddTranslation(c, path.New("json", fmt.Sprintf("sshAuthorizedKeys.%d", i+offset)))
287+
to.SSHAuthorizedKeys = append(to.SSHAuthorizedKeys, types.SSHAuthorizedKey(sshKey))
288+
}
289+
}
290+
}
291+
292+
return
293+
}
294+
220295
func (c Config) processTrees(ret *types.Config, options common.TranslateOptions) (translate.TranslationSet, report.Report) {
221296
ts := translate.NewTranslationSet("yaml", "json")
222297
var r report.Report

base/v0_5_exp/translate_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package v0_5_exp
1616

1717
import (
18+
"fmt"
1819
"io/ioutil"
1920
"net"
2021
"os"
@@ -1616,6 +1617,129 @@ func TestTranslateKernelArguments(t *testing.T) {
16161617
}
16171618
}
16181619

1620+
// TestTranslateSSHAuthorizedKeyResource tests translating the butane passwd.users[i].ssh_authorized_keys_local[j] entries to ignition passwd.users[i].ssh_authorized_keys[j] entries.
1621+
func TestTranslateSSHAuthorizedKeyResource(t *testing.T) {
1622+
sshKeyDir := t.TempDir()
1623+
randomDir := t.TempDir()
1624+
var sshKey = "ssh-rsa AAAAAAAAA"
1625+
var sshKeyFileName = "id_rsa.pub"
1626+
var sshKeyMultipleKeysFileName = "multiple.pubs"
1627+
var sshKeyNonExistingFileName = "id_ed25519.pub"
1628+
var sshKeyEmptyFileName = "empty.pub"
1629+
var sshKeyBlankFileName = "blank.pub"
1630+
err := ioutil.WriteFile(filepath.Join(sshKeyDir, sshKeyFileName), []byte(sshKey), 0644)
1631+
if err != nil {
1632+
t.Error(err)
1633+
}
1634+
err = ioutil.WriteFile(filepath.Join(sshKeyDir, sshKeyMultipleKeysFileName), []byte(fmt.Sprintf("%s\n%s\n\n", sshKey, sshKey)), 0644)
1635+
if err != nil {
1636+
t.Error(err)
1637+
}
1638+
err = ioutil.WriteFile(filepath.Join(sshKeyDir, sshKeyEmptyFileName), []byte(""), 0644)
1639+
if err != nil {
1640+
t.Error(err)
1641+
}
1642+
err = ioutil.WriteFile(filepath.Join(sshKeyDir, sshKeyBlankFileName), []byte("\n\t\n\n\n\n\n\n"), 0644)
1643+
if err != nil {
1644+
t.Error(err)
1645+
}
1646+
tests := []struct {
1647+
name string
1648+
in PasswdUser
1649+
out types.PasswdUser
1650+
report string
1651+
fileDir string
1652+
}{
1653+
{
1654+
"empty user",
1655+
PasswdUser{},
1656+
types.PasswdUser{},
1657+
"",
1658+
sshKeyDir,
1659+
},
1660+
{
1661+
"valid ssh_keys_inline",
1662+
PasswdUser{SSHAuthorizedKeys: []SSHAuthorizedKey{SSHAuthorizedKey(sshKey)}},
1663+
types.PasswdUser{SSHAuthorizedKeys: []types.SSHAuthorizedKey{types.SSHAuthorizedKey(sshKey)}},
1664+
"",
1665+
sshKeyDir,
1666+
},
1667+
{
1668+
"valid ssh_keys_local",
1669+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyFileName}},
1670+
types.PasswdUser{SSHAuthorizedKeys: []types.SSHAuthorizedKey{types.SSHAuthorizedKey(sshKey)}},
1671+
"",
1672+
sshKeyDir,
1673+
},
1674+
{
1675+
"valid ssh_keys_local with multiple keys per file",
1676+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyMultipleKeysFileName}},
1677+
types.PasswdUser{SSHAuthorizedKeys: []types.SSHAuthorizedKey{types.SSHAuthorizedKey(sshKey), types.SSHAuthorizedKey(sshKey)}},
1678+
"",
1679+
sshKeyDir,
1680+
},
1681+
{
1682+
"valid ssh_keys_local and ssh_keys",
1683+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyFileName}, SSHAuthorizedKeys: []SSHAuthorizedKey{SSHAuthorizedKey(sshKey)}},
1684+
types.PasswdUser{SSHAuthorizedKeys: []types.SSHAuthorizedKey{types.SSHAuthorizedKey(sshKey), types.SSHAuthorizedKey(sshKey)}},
1685+
"",
1686+
sshKeyDir,
1687+
},
1688+
{
1689+
"valid ssh_keys_local with multiple keys per file and ssh_keys",
1690+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyMultipleKeysFileName}, SSHAuthorizedKeys: []SSHAuthorizedKey{SSHAuthorizedKey(sshKey)}},
1691+
types.PasswdUser{SSHAuthorizedKeys: []types.SSHAuthorizedKey{types.SSHAuthorizedKey(sshKey), types.SSHAuthorizedKey(sshKey), types.SSHAuthorizedKey(sshKey)}},
1692+
"",
1693+
sshKeyDir,
1694+
},
1695+
{
1696+
"non existing ssh_keys_local file name",
1697+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyNonExistingFileName}},
1698+
types.PasswdUser{},
1699+
"error at $.ssh_authorized_keys_local: open %FileDir%/id_ed25519.pub: no such file or directory\n",
1700+
sshKeyDir,
1701+
},
1702+
{
1703+
"empty ssh_keys_local file",
1704+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyEmptyFileName}},
1705+
types.PasswdUser{},
1706+
"error at $.ssh_authorized_keys_local: local file to be embedded must not be empty\n",
1707+
sshKeyDir,
1708+
},
1709+
{
1710+
"blank ssh_keys_local file",
1711+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyBlankFileName}},
1712+
types.PasswdUser{},
1713+
"error at $.ssh_authorized_keys_local: local file to be embedded must not be empty\n",
1714+
sshKeyDir,
1715+
},
1716+
{
1717+
"missing embed directory",
1718+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyFileName}},
1719+
types.PasswdUser{},
1720+
"error at $.ssh_authorized_keys_local: local file paths are relative to a files directory that must be specified with -d/--files-dir\n",
1721+
"",
1722+
},
1723+
{
1724+
"wrong embed directory",
1725+
PasswdUser{SSHAuthorizedKeysLocal: []string{sshKeyFileName}},
1726+
types.PasswdUser{},
1727+
"error at $.ssh_authorized_keys_local: open %FileDir%/id_rsa.pub: no such file or directory\n",
1728+
randomDir,
1729+
},
1730+
}
1731+
1732+
for _, test := range tests {
1733+
t.Run(test.name, func(t *testing.T) {
1734+
actual, translations, r := translatePasswdUser(test.in, common.TranslateOptions{FilesDir: test.fileDir})
1735+
assert.Equal(t, test.out, actual, "translation mismatch")
1736+
expectedReport := strings.ReplaceAll(test.report, "%FileDir%", test.fileDir)
1737+
assert.Equal(t, expectedReport, r.String(), "bad report")
1738+
assert.NoError(t, translations.DebugVerifyCoverage(actual), "incomplete TranslationSet coverage")
1739+
})
1740+
}
1741+
}
1742+
16191743
// TestToIgn3_4 tests the config.ToIgn3_4 function ensuring it will generate a valid config even when empty. Not much else is
16201744
// tested since it uses the Ignition translation code which has it's own set of tests.
16211745
func TestToIgn3_4(t *testing.T) {

config/common/errors.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ var (
3434
ErrTooManyResourceSources = errors.New("only one of the following can be set: inline, local, source")
3535
ErrFilesDirEscape = errors.New("local file path traverses outside the files directory")
3636
ErrFileType = errors.New("trees may only contain files, directories, and symlinks")
37+
ErrFileEmpty = errors.New("local file to be embedded must not be empty")
3738
ErrNodeExists = errors.New("matching filesystem node has existing contents or different type")
3839
ErrNoFilesDir = errors.New("local file paths are relative to a files directory that must be specified with -d/--files-dir")
3940
ErrTreeNotDirectory = errors.New("root of tree must be a directory")
@@ -69,4 +70,5 @@ var (
6970
ErrUserFieldSupport = errors.New("fields other than \"name\" and \"ssh_authorized_keys\" are not supported in this spec version")
7071
ErrUserNameSupport = errors.New("users other than \"core\" are not supported in this spec version")
7172
ErrKernelArgumentSupport = errors.New("this field cannot be used for kernel arguments in this spec version; use openshift.kernel_arguments instead")
73+
ErrSSHKeyEmpty = errors.New("ssh key must not be empty")
7274
)

config/openshift/v4_11_exp/translate_test.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -415,17 +415,18 @@ func TestValidateSupport(t *testing.T) {
415415
Groups: []base.Group{
416416
"z",
417417
},
418-
HomeDir: util.StrToPtr("/home/drum"),
419-
NoCreateHome: util.BoolToPtr(true),
420-
NoLogInit: util.BoolToPtr(true),
421-
NoUserGroup: util.BoolToPtr(true),
422-
PasswordHash: util.StrToPtr("corned beef"),
423-
PrimaryGroup: util.StrToPtr("wheel"),
424-
SSHAuthorizedKeys: []base.SSHAuthorizedKey{"value"},
425-
Shell: util.StrToPtr("/bin/tcsh"),
426-
ShouldExist: util.BoolToPtr(false),
427-
System: util.BoolToPtr(true),
428-
UID: util.IntToPtr(42),
418+
HomeDir: util.StrToPtr("/home/drum"),
419+
NoCreateHome: util.BoolToPtr(true),
420+
NoLogInit: util.BoolToPtr(true),
421+
NoUserGroup: util.BoolToPtr(true),
422+
PasswordHash: util.StrToPtr("corned beef"),
423+
PrimaryGroup: util.StrToPtr("wheel"),
424+
SSHAuthorizedKeys: []base.SSHAuthorizedKey{"value"},
425+
SSHAuthorizedKeysLocal: []string{},
426+
Shell: util.StrToPtr("/bin/tcsh"),
427+
ShouldExist: util.BoolToPtr(false),
428+
System: util.BoolToPtr(true),
429+
UID: util.IntToPtr(42),
429430
},
430431
{
431432
Name: "bovik",

docs/config-fcos-v1_5-exp.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ The Fedora CoreOS configuration is a YAML document conforming to the following s
178178
* **name** (string): the username for the account.
179179
* **_password_hash_** (string): the hashed password for the account.
180180
* **_ssh_authorized_keys_** (list of strings): a list of SSH keys to be added as an SSH key fragment at `.ssh/authorized_keys.d/ignition` in the user's home directory. All SSH keys must be unique.
181+
* **_ssh_authorized_keys_local_** (list of strings): a list of paths pointing at SSH key files to be added as an SSH key fragment at `.ssh/authorized_keys.d/ignition` in the user's home directory. All SSH keys must be unique. May contain one SSH key per line in each file.
181182
* **_uid_** (integer): the user ID of the account.
182183
* **_gecos_** (string): the GECOS field of the account.
183184
* **_home_dir_** (string): the home directory of the account.

docs/config-openshift-v4_10.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ The OpenShift configuration is a YAML document conforming to the following speci
146146
* **_passwd_** (object): describes the desired additions to the passwd database.
147147
* **_users_** (list of objects): the list of accounts that shall exist. All users must have a unique `name`.
148148
* **name** (string): the username for the account. Must be `core`.
149-
* **_ssh_authorized_keys_** (list of strings): a list of SSH keys to be added to `.ssh/authorized_keys` in the user's home directory. All SSH keys must be unique.
149+
* **_ssh_authorized_keys_** (list of strings): a list of SSH keys to be added as an SSH key fragment at `.ssh/authorized_keys.d/ignition` in the user's home directory. All SSH keys must be unique.
150+
* **_ssh_authorized_keys_local_** (list of strings): a list of paths pointing at SSH key files to be added as an SSH key fragment at `.ssh/authorized_keys.d/ignition` in the user's home directory. All SSH keys must be unique. May contain one SSH key per line in each file.
150151
* **_boot_device_** (object): describes the desired boot device configuration. At least one of `luks` or `mirror` must be specified.
151152
* **_layout_** (string): the disk layout of the target OS image. Supported values are `aarch64`, `ppc64le`, and `x86_64`. Defaults to `x86_64`.
152153
* **_luks_** (object): describes the clevis configuration for encrypting the root filesystem.

docs/upgrading-fcos.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ Occasionally, changes are made to Fedora CoreOS Butane configs (those that speci
1313
1. TOC
1414
{:toc}
1515

16+
## From Version 1.4.0 to Version 1.5.0
17+
18+
There are no breaking changes between versions 1.4.0 and 1.5.0 of the `fcos` configuration specification. Any valid 1.4.0 configuration can be updated to a 1.5.0 configuration by changing the version string in the config.
19+
20+
The following is a list of notable new features.
21+
22+
### Local SSH Key references
23+
24+
SSH Keys are now embeddable via file references to local files. The specified path is relative to a local _files-dir_, specified with the `-d`/`--files-dir` option to Butane. If no _files-dir_ is specified, this functionality is unavailable.
25+
26+
<!-- butane-config -->
27+
```yaml
28+
variant: fcos
29+
version: 1.5.0-experimental
30+
passwd:
31+
users:
32+
- name: core
33+
ssh_authorized_keys_local:
34+
- id_rsa.pub
35+
```
36+
1637
## From Version 1.3.0 to 1.4.0
1738
1839
There are no breaking changes between versions 1.3.0 and 1.4.0 of the `fcos` configuration specification. Any valid 1.3.0 configuration can be updated to a 1.4.0 configuration by changing the version string in the config.

docs/upgrading-openshift.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ Occasionally, changes are made to OpenShift Butane configs (those that specify `
1313
1. TOC
1414
{:toc}
1515

16+
## From Version 4.10.0 to Version 4.11.0
17+
18+
There are no breaking changes between versions 4.10.0 and 4.11.0 of the `openshift` configuration specification. Any valid 4.10.0 configuration can be updated to a 4.11.0 configuration by changing the version string in the config.
19+
20+
### Local SSH Key references
21+
22+
SSH Keys are now embeddable via file references to local files. The specified path is relative to a local _files-dir_, specified with the `-d`/`--files-dir` option to Butane. If no _files-dir_ is specified, this functionality is unavailable.
23+
24+
<!-- butane-config -->
25+
```yaml
26+
variant: openshift
27+
version: 4.11.0-experimental
28+
metadata:
29+
name: minimal-config
30+
labels:
31+
machineconfiguration.openshift.io/role: worker
32+
passwd:
33+
users:
34+
- name: core
35+
ssh_authorized_keys_local:
36+
- id_rsa.pub
37+
```
38+
1639
## From Version 4.9.0 to 4.10.0
1740
1841
There are no breaking changes between versions 4.9.0 and 4.10.0 of the `openshift` configuration specification. Any valid 4.9.0 configuration can be updated to a 4.10.0 configuration by changing the version string in the config.

test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ trap 'rm -r tmpdocs' EXIT
2828
# Create files-dir contents expected by configs
2929
mkdir -p tmpdocs/files-dir/tree
3030
touch tmpdocs/files-dir/{config.ign,ca.pem,file,file-epilogue,local-file3}
31+
echo "ssh-rsa AAAA" > tmpdocs/files-dir/id_rsa.pub
3132

3233
for doc in docs/*md
3334
do

0 commit comments

Comments
 (0)