Skip to content

Commit 1bfac33

Browse files
committed
feat : PoC for automated Windows guest via autounattend.xml Implements issue #4852.
Signed-off-by: liketosweep <liketosweep@gmail.com>
1 parent 7ba5237 commit 1bfac33

8 files changed

Lines changed: 914 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ lima.REJECTED.yaml
44
default-template.yaml
55
schema-limayaml.json
66
.config
7+
poc-windows-guest/images/
8+
poc-windows-guest/tmp/
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package autounattend
2+
3+
import (
4+
_ "embed"
5+
"bytes"
6+
"encoding/base64"
7+
"fmt"
8+
"html/template"
9+
"strings"
10+
)
11+
12+
//go:embed template.xml
13+
var rawTemplate string
14+
15+
var tmpl = template.Must(
16+
template.New("autounattend").
17+
Funcs(template.FuncMap{
18+
"encodePassword": encodeUTF16LE,
19+
"joinLines": func(ss []string) string { return strings.Join(ss, "\n") },
20+
"inc": func(n int) int { return n + 1 },
21+
}).
22+
Parse(rawTemplate),
23+
)
24+
25+
type Config struct {
26+
Hostname string
27+
Username string
28+
Password string
29+
SSHPublicKeys []string
30+
Locale string
31+
TimeZone string
32+
WindowsEditionIndex int
33+
ExtraCommands []string
34+
}
35+
36+
func (c *Config) setDefaults() {
37+
if c.Username == "" {
38+
c.Username = "limauser"
39+
}
40+
if c.Locale == "" {
41+
c.Locale = "en-US"
42+
}
43+
if c.TimeZone == "" {
44+
c.TimeZone = "GMT Standard Time"
45+
}
46+
if c.WindowsEditionIndex == 0 {
47+
c.WindowsEditionIndex = 1
48+
}
49+
}
50+
51+
func (c *Config) validate() error {
52+
switch {
53+
case c.Hostname == "":
54+
return fmt.Errorf("autounattend: Hostname is required")
55+
case len(c.Hostname) > 15:
56+
return fmt.Errorf("autounattend: Hostname %q exceeds 15-char NetBIOS limit", c.Hostname)
57+
case c.Password == "":
58+
return fmt.Errorf("autounattend: Password is required")
59+
case len(c.SSHPublicKeys) == 0:
60+
return fmt.Errorf("autounattend: at least one SSH public key is required")
61+
case c.WindowsEditionIndex < 1:
62+
return fmt.Errorf("autounattend: WindowsEditionIndex must be >= 1")
63+
}
64+
return nil
65+
}
66+
67+
func Generate(cfg Config) ([]byte, error) {
68+
cfg.setDefaults()
69+
if err := cfg.validate(); err != nil {
70+
return nil, err
71+
}
72+
var buf bytes.Buffer
73+
if err := tmpl.Execute(&buf, cfg); err != nil {
74+
return nil, fmt.Errorf("autounattend: render failed: %w", err)
75+
}
76+
return buf.Bytes(), nil
77+
}
78+
79+
// encodeUTF16LE implements the Microsoft answer-file password encoding:
80+
// base64( UTF-16LE( plaintext + "Password" ) )
81+
func encodeUTF16LE(plaintext string) string {
82+
s := plaintext + "Password"
83+
b := make([]byte, len(s)*2)
84+
for i, r := range s {
85+
b[i*2] = byte(r)
86+
b[i*2+1] = byte(r >> 8)
87+
}
88+
return base64.StdEncoding.EncodeToString(b)
89+
}
90+
91+
// SanitiseHostname converts an arbitrary string into a valid Windows
92+
// NetBIOS computer name (<=15 chars, alphanumeric + hyphen, no leading/trailing hyphen).
93+
func SanitiseHostname(name string) string {
94+
out := make([]byte, 0, len(name))
95+
for _, r := range name {
96+
switch {
97+
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
98+
out = append(out, byte(r))
99+
default:
100+
out = append(out, '-')
101+
}
102+
}
103+
s := strings.Trim(string(out), "-")
104+
if len(s) > 15 {
105+
s = s[:15]
106+
}
107+
if s == "" {
108+
return "lima-windows"
109+
}
110+
return s
111+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package autounattend_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/lima-vm/lima/v2/pkg/windows/autounattend"
8+
)
9+
10+
func baseConfig() autounattend.Config {
11+
return autounattend.Config{
12+
Hostname: "lima-win",
13+
Username: "limauser",
14+
Password: "T3stP@ssw0rd!",
15+
SSHPublicKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA user@host"},
16+
WindowsEditionIndex: 1,
17+
}
18+
}
19+
20+
func TestGenerate_ContainsRequiredPasses(t *testing.T) {
21+
xml, err := autounattend.Generate(baseConfig())
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
for _, pass := range []string{"windowsPE", "specialize", "oobeSystem"} {
26+
if !strings.Contains(string(xml), pass) {
27+
t.Errorf("missing pass: %s", pass)
28+
}
29+
}
30+
}
31+
32+
func TestGenerate_ContainsVirtIODriverPaths(t *testing.T) {
33+
xml, err := autounattend.Generate(baseConfig())
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
for _, driver := range []string{"viostor", "NetKVM", "viofs"} {
38+
if !strings.Contains(string(xml), driver) {
39+
t.Errorf("missing VirtIO driver path for: %s", driver)
40+
}
41+
}
42+
}
43+
44+
func TestGenerate_SSHKeyPresent(t *testing.T) {
45+
xml, err := autounattend.Generate(baseConfig())
46+
if err != nil {
47+
t.Fatal(err)
48+
}
49+
if !strings.Contains(string(xml), "ssh-ed25519") {
50+
t.Error("SSH public key missing from output")
51+
}
52+
}
53+
54+
func TestGenerate_PasswordNeverPlaintext(t *testing.T) {
55+
cfg := baseConfig()
56+
xml, err := autounattend.Generate(cfg)
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
if strings.Contains(string(xml), cfg.Password) {
61+
t.Error("SECURITY: plaintext password present in generated XML")
62+
}
63+
if !strings.Contains(string(xml), "<PlainText>false</PlainText>") {
64+
t.Error("PlainText flag not set to false")
65+
}
66+
}
67+
68+
func TestGenerate_ValidationErrors(t *testing.T) {
69+
cases := []struct {
70+
name string
71+
mutate func(*autounattend.Config)
72+
}{
73+
{"empty hostname", func(c *autounattend.Config) { c.Hostname = "" }},
74+
{"hostname too long", func(c *autounattend.Config) { c.Hostname = "this-is-way-too-long" }},
75+
{"empty password", func(c *autounattend.Config) { c.Password = "" }},
76+
{"no ssh keys", func(c *autounattend.Config) { c.SSHPublicKeys = nil }},
77+
{"bad edition index", func(c *autounattend.Config) { c.WindowsEditionIndex = -1 }},
78+
}
79+
for _, tc := range cases {
80+
t.Run(tc.name, func(t *testing.T) {
81+
cfg := baseConfig()
82+
tc.mutate(&cfg)
83+
if _, err := autounattend.Generate(cfg); err == nil {
84+
t.Errorf("expected error for %q, got nil", tc.name)
85+
}
86+
})
87+
}
88+
}
89+
90+
func TestSanitiseHostname(t *testing.T) {
91+
cases := []struct{ in, want string }{
92+
{"my-vm", "my-vm"},
93+
{"my_special_vm!", "my-special-vm"},
94+
{"this-is-way-too-long-for-netbios", "this-is-way-too"},
95+
{"---", "lima-windows"},
96+
{"", "lima-windows"},
97+
}
98+
for _, tc := range cases {
99+
got := autounattend.SanitiseHostname(tc.in)
100+
if got != tc.want {
101+
t.Errorf("SanitiseHostname(%q) = %q, want %q", tc.in, got, tc.want)
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)