Skip to content

Commit 69c0111

Browse files
committed
feat: add --user/--password flags for cloudimg credential injection
- Replace global --root-password with per-VM --user/--password flags (defaults: root/cocoon). Credentials are transient (json:"-") and never persisted in the VM record. - Non-root users are created via cloud-init runcmd (useradd + chpasswd + NOPASSWD sudoers). Root stays locked when a custom user is set. - Validate username (Linux format) and reject shell-unsafe passwords. - OCI Dockerfiles: add cocoon.ssh.username/password LABELs. - Remove DefaultRootPassword from global config.
1 parent 72adfe4 commit 69c0111

15 files changed

Lines changed: 85 additions & 34 deletions

File tree

KNOWN_ISSUES.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,27 @@ OCI VMs use the kernel `ip=` boot parameter for network configuration. While mul
2727

2828
**Workaround**: the post-clone setup hints write persistent MAC-based systemd-networkd configs for **all** NICs. These survive reboots and correctly configure every interface regardless of the kernel `ip=` limitation.
2929

30+
## Non-root user creation requires cloud-init final stage
31+
32+
When `--user` specifies a non-root username (e.g. `--user admin`), the user is created via cloud-init `runcmd` which runs in the **final stage** — after networking, SSH key generation, and other modules. This means:
33+
34+
- The user does not exist until cloud-init fully completes (typically 20-30s after boot)
35+
- SSH login as the custom user will fail if attempted before cloud-init finishes
36+
- `cloud-init status` shows `done` when the user is ready
37+
38+
The root user's password is set via `chpasswd` (config stage, earlier) and is available sooner, but `--user admin` deliberately does not set a root password — root stays locked for security.
39+
40+
**Workaround**: wait for `cloud-init status: done` before attempting SSH. The default `root`/`cocoon` credentials use the faster `chpasswd` path and are available immediately after SSH starts.
41+
42+
## Clone preserves guest credentials from snapshot
43+
44+
Clone regenerates cidata for **network reconfiguration only** — it does not inject new user or password settings. The cloned VM's credentials are whatever the source VM had in `/etc/shadow` at snapshot time.
45+
46+
- `--user`/`--password` flags are not available on `cocoon vm clone`
47+
- If you need different credentials, change them inside the guest after boot
48+
49+
This is by design: clone restores the VM's exact state including all account settings.
50+
3051
## Clone/restore disk queue count is immutable
3152

3253
When cloning or restoring a VM with a different `--cpu` value, the disk `num_queues` (one queue per vCPU) retains the snapshot's original value. This is because `num_queues` is part of the virtio-blk device state baked into the binary snapshot — changing it in `config.json` causes Cloud Hypervisor to crash on `vm.restore`.

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Lightweight MicroVM engine with dual hypervisor backends: [Cloud Hypervisor](htt
1313
- **Multi-queue virtio-net** — TAP devices created with per-vCPU queue pairs; configurable ring depth (`--queue-size`, default 512); TSO/UFO/csum offload enabled by default
1414
- **TC redirect I/O path** — veth ↔ TAP wired via ingress qdisc + mirred redirect (no bridge in the data path)
1515
- **DNS configuration** — custom DNS servers injected into VMs via kernel cmdline (OCI) or cloud-init network-config (cloudimg)
16-
- **Cloud-init metadata** — automatic NoCloud cidata FAT12 disk for cloudimg VMs (hostname, root password, multi-NIC Netplan v2 network-config); cidata is automatically skipped on subsequent boots
16+
- **Cloud-init metadata** — automatic NoCloud cidata FAT12 disk for cloudimg VMs (hostname, configurable user/password via `--user`/`--password`, multi-NIC Netplan v2 network-config); cidata is automatically skipped on subsequent boots
1717
- **Hugepages** — automatic detection of host hugepage configuration; VM memory backed by hugepages when available
1818
- **Memory balloon** — 25% of memory returned via virtio-balloon (deflate-on-OOM, free-page reporting) when memory >= 256 MiB
1919
- **Graceful shutdown** — ACPI power-button for UEFI VMs with configurable timeout, fallback to SIGTERM → SIGKILL
@@ -163,7 +163,6 @@ cocoon
163163
| `--log-level` | `COCOON_LOG_LEVEL` | `info` | Log level: debug, info, warn, error |
164164
| `--cni-conf-dir` | `COCOON_CNI_CONF_DIR` | `/etc/cni/net.d` | CNI plugin config directory |
165165
| `--cni-bin-dir` | `COCOON_CNI_BIN_DIR` | `/opt/cni/bin` | CNI plugin binary directory |
166-
| `--root-password` | `COCOON_DEFAULT_ROOT_PASSWORD` | | Default root password for cloudimg VMs |
167166
| `--dns` | `COCOON_DNS` | `8.8.8.8,1.1.1.1` | DNS servers for VMs (comma separated) |
168167

169168
## VM Flags
@@ -182,6 +181,8 @@ Applies to `cocoon vm create`, `cocoon vm run`, and `cocoon vm debug`:
182181
| `--disk-queue-size` | `0` (default 512) | Virtio-blk ring depth per device (CH only, ignored by FC) |
183182
| `--network` | empty (default) | CNI conflist name (empty = first conflist) |
184183
| `--bridge` | empty | TAP-on-bridge mode (value is bridge device, e.g. `cni0`); mutually exclusive with `--network` |
184+
| `--user` | `root` | Guest username for cloud-init (cloudimg only) |
185+
| `--password` | `cocoon` | Guest password for cloud-init (cloudimg only) |
185186
| `--no-direct-io` | `false` | Disable O_DIRECT on writable disks (use page cache; CH only, useful for dev/test with few VMs) |
186187
| `--windows` | `false` | Windows guest (UEFI boot, kvm_hyperv=on, no cidata) |
187188

@@ -320,12 +321,14 @@ All `.conflist` files in `--cni-conf-dir` (default `/etc/cni/net.d`) are loaded
320321
Cloudimg VMs receive a NoCloud cidata disk (FAT12 with `CIDATA` volume label) containing:
321322

322323
- **meta-data**: instance ID and hostname
323-
- **user-data**: `#cloud-config` with optional root password (`--root-password`)
324+
- **user-data**: `#cloud-config` with configurable user/password (`--user`/`--password`, defaults to `root`/`cocoon`)
324325
- **network-config**: Netplan v2 format with MAC-matched ethernets, static IP/gateway/DNS per NIC
325326
- **user-data write_files**: fallback `/etc/systemd/network/15-cocoon-id*.network` files matching current MAC (`MACAddress=`), used when netplan PERM-MAC matching cannot apply
326327

327328
The cidata disk is **automatically excluded on subsequent boots** — after the first successful start, the VM record is marked as `first_booted` and the cidata disk is no longer attached, preventing cloud-init from re-running.
328329

330+
Note: `--user`/`--password` only apply to **cloudimg** VMs (cloud-init). OCI VM images bake credentials at build time — cocoon OCI Dockerfiles annotate them via `LABEL cocoon.ssh.username` / `cocoon.ssh.password` for external tooling (e.g. glance, vk-cocoon).
331+
329332
## Windows Support
330333

331334
Cocoon supports Windows guests via the `--windows` flag:

cmd/core/helpers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error
253253
queueSize, _ := cmd.Flags().GetInt("queue-size")
254254
diskQueueSize, _ := cmd.Flags().GetInt("disk-queue-size")
255255
network, _ := cmd.Flags().GetString("network")
256+
user, _ := cmd.Flags().GetString("user")
257+
password, _ := cmd.Flags().GetString("password")
256258
noDirectIO, _ := cmd.Flags().GetBool("no-direct-io")
257259
windows, _ := cmd.Flags().GetBool("windows")
258260

@@ -278,6 +280,8 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error
278280
DiskQueueSize: diskQueueSize,
279281
Image: image,
280282
Network: network,
283+
User: user,
284+
Password: password,
281285
NoDirectIO: noDirectIO,
282286
Windows: windows,
283287
}

cmd/root.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ var (
4040
cmd.PersistentFlags().String("log-dir", "", "log directory")
4141
cmd.PersistentFlags().String("cni-conf-dir", "", "CNI plugin config directory (default: /etc/cni/net.d)")
4242
cmd.PersistentFlags().String("cni-bin-dir", "", "CNI plugin binary directory (default: /opt/cni/bin)")
43-
cmd.PersistentFlags().String("root-password", "", "default root password for cloudimg VMs")
4443
cmd.PersistentFlags().String("dns", "", `DNS servers for VMs, comma or semicolon separated (default: "8.8.8.8,1.1.1.1")`)
4544
cmd.PersistentFlags().String("log-level", "", `log level: debug, info, warn, error (default: "info")`)
4645

@@ -49,7 +48,6 @@ var (
4948
_ = viper.BindPFlag("log_dir", cmd.PersistentFlags().Lookup("log-dir"))
5049
_ = viper.BindPFlag("cni_conf_dir", cmd.PersistentFlags().Lookup("cni-conf-dir"))
5150
_ = viper.BindPFlag("cni_bin_dir", cmd.PersistentFlags().Lookup("cni-bin-dir"))
52-
_ = viper.BindPFlag("default_root_password", cmd.PersistentFlags().Lookup("root-password"))
5351
_ = viper.BindPFlag("dns", cmd.PersistentFlags().Lookup("dns"))
5452
_ = viper.BindPFlag("log.level", cmd.PersistentFlags().Lookup("log-level"))
5553

cmd/vm/commands.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ func addVMFlags(cmd *cobra.Command) {
159159
cmd.Flags().Int("disk-queue-size", 0, "virtio-blk ring depth per device (0 = default 512; CH only, ignored by FC)") //nolint:mnd
160160
cmd.Flags().String("network", "", "CNI conflist name (empty = default); mutually exclusive with --bridge")
161161
cmd.Flags().String("bridge", "", "use TAP-on-bridge instead of CNI (value is bridge device, e.g. cni0); VM gets IP via DHCP from the bridge")
162+
cmd.Flags().String("user", "root", "guest username for cloud-init (cloudimg only)")
163+
cmd.Flags().String("password", "cocoon", "guest password for cloud-init (cloudimg only)")
162164
cmd.Flags().Bool("no-direct-io", false, "disable O_DIRECT on writable disks (use page cache instead; CH only)")
163165
cmd.Flags().Bool("windows", false, "Windows guest (UEFI boot, kvm_hyperv=on, no cidata)")
164166
}

config/config.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,6 @@ type Config struct {
5151
// CNIBinDir is the directory for CNI plugin binaries.
5252
// Default: /opt/cni/bin.
5353
CNIBinDir string `json:"cni_bin_dir" mapstructure:"cni_bin_dir"`
54-
// DefaultRootPassword is the root password injected into cloudimg VMs
55-
// via cloud-init metadata. Empty means no password is set.
56-
DefaultRootPassword string `json:"default_root_password" mapstructure:"default_root_password"`
5754
// DNS is a comma or semicolon separated list of DNS server addresses
5855
// injected into VM network configuration.
5956
// Env: COCOON_DNS. Default: "8.8.8.8,1.1.1.1".

hypervisor/cloudhypervisor/create.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,11 @@ func (ch *CloudHypervisor) generateCidata(vmID string, vmCfg *types.VMConfig, ne
168168
return fmt.Errorf("parse DNS servers: %w", err)
169169
}
170170
metaCfg := &metadata.Config{
171-
InstanceID: vmID,
172-
Hostname: vmCfg.Name,
173-
RootPassword: ch.conf.DefaultRootPassword,
174-
DNS: dns,
171+
InstanceID: vmID,
172+
Hostname: vmCfg.Name,
173+
Username: vmCfg.User,
174+
Password: vmCfg.Password,
175+
DNS: dns,
175176
}
176177
for _, n := range networkConfigs {
177178
if n == nil || n.Mac == "" {

metadata/metadata.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,23 @@ var (
2626
// It also writes fallback systemd-networkd units matching current MAC so
2727
// clone reinit can survive netplan PERM-MAC mismatch on later reboots.
2828
userDataTmpl = template.Must(template.New("user-data").Funcs(tmplFuncs).Parse(`#cloud-config
29-
warnings:
30-
dsid_missing_source: off
31-
{{- if .RootPassword}}
29+
{{- if .Password}}
3230
chpasswd:
3331
expire: false
3432
list:
35-
- 'root:{{yamlQuote .RootPassword}}'
33+
- '{{.Username}}:{{yamlQuote .Password}}'
3634
ssh_pwauth: true
35+
{{- if eq .Username "root"}}
3736
disable_root: false
3837
{{- end}}
38+
{{- if and .Username (ne .Username "root")}}
39+
runcmd:
40+
- [sh, -c, 'id {{.Username}} >/dev/null 2>&1 || useradd -m -s /bin/bash -N {{.Username}}']
41+
- [usermod, -aG, sudo, '{{.Username}}']
42+
- [sh, -c, 'echo ''{{.Username}}:{{.Password}}'' | chpasswd']
43+
- [sh, -c, 'echo ''{{.Username}} ALL=(ALL) NOPASSWD:ALL'' > /etc/sudoers.d/cocoon-{{.Username}}']
44+
{{- end}}
45+
{{- end}}
3946
{{- if .Networks}}
4047
write_files:
4148
{{- range $i, $n := .Networks}}
@@ -103,11 +110,12 @@ ethernets:
103110

104111
// Config holds the inputs for generating cloud-init NoCloud metadata.
105112
type Config struct {
106-
InstanceID string
107-
Hostname string
108-
RootPassword string
109-
Networks []NetworkInfo
110-
DNS []string // e.g. ["8.8.8.8", "8.8.4.4"]
113+
InstanceID string
114+
Hostname string
115+
Username string
116+
Password string
117+
Networks []NetworkInfo
118+
DNS []string // e.g. ["8.8.8.8", "8.8.4.4"]
111119
}
112120

113121
// NetworkInfo describes a single guest network interface for cloud-init.

metadata/metadata_test.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88

99
func TestUserData_NoBootcmd(t *testing.T) {
1010
cfg := &Config{
11-
RootPassword: "test",
11+
Username: "root", Password: "test",
1212
Networks: []NetworkInfo{
1313
{IP: "10.0.0.2", Prefix: 24, Mac: "aa:bb:cc:dd:ee:f0"},
1414
},
@@ -26,9 +26,6 @@ func TestUserData_NoBootcmd(t *testing.T) {
2626
if !strings.Contains(out, "root:test") {
2727
t.Errorf("root password missing: %s", out)
2828
}
29-
if !strings.Contains(out, "dsid_missing_source: off") {
30-
t.Errorf("cloud-init warning suppression missing: %s", out)
31-
}
3229
if !strings.Contains(out, "write_files:") {
3330
t.Errorf("write_files missing: %s", out)
3431
}
@@ -188,7 +185,7 @@ func TestGenerate_ProducesValidFAT12(t *testing.T) {
188185
cfg := &Config{
189186
InstanceID: "test-id",
190187
Hostname: "test-vm",
191-
RootPassword: "pass",
188+
Username: "root", Password: "pass",
192189
Networks: []NetworkInfo{
193190
{IP: "10.0.0.2", Prefix: 24, Gateway: "10.0.0.1", Mac: "aa:bb:cc:dd:ee:ff"},
194191
},
@@ -223,7 +220,7 @@ func TestGenerate_NoNetworks(t *testing.T) {
223220
cfg := &Config{
224221
InstanceID: "test-id",
225222
Hostname: "test-vm",
226-
RootPassword: "pass",
223+
Username: "root", Password: "pass",
227224
}
228225

229226
var buf bytes.Buffer
@@ -238,9 +235,6 @@ func TestGenerate_NoNetworks(t *testing.T) {
238235
if strings.Contains(raw, "ethernets:") {
239236
t.Error("network-config should not appear without networks")
240237
}
241-
if !strings.Contains(raw, "dsid_missing_source: off") {
242-
t.Error("cloud-init warning suppression should always appear in user-data")
243-
}
244238
if strings.Contains(raw, "write_files:") {
245239
t.Error("write_files should not appear without networks")
246240
}

os-image/ubuntu/22.04/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Use the latest Ubuntu 22.04 (Jammy) LTS
22
FROM ubuntu:22.04
3+
LABEL cocoon.ssh.username="root" cocoon.ssh.password="cocoon"
34

45
ENV DEBIAN_FRONTEND=noninteractive
56

0 commit comments

Comments
 (0)