Skip to content

Commit 5962652

Browse files
authored
feat(qemu): add DNS search domains (#2481)
* feat(qemu): add DNS search domains Signed-off-by: egibs <20933572+egibs@users.noreply.github.com>
1 parent bdf1d73 commit 5962652

File tree

2 files changed

+337
-1
lines changed

2 files changed

+337
-1
lines changed

pkg/container/qemu_runner.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,23 @@ func createMicroVM(ctx context.Context, cfg *Config) error {
792792
baseargs = append(baseargs, "-nodefaults")
793793
baseargs = append(baseargs, serialArgs...)
794794
// use -netdev + -device instead of -nic, as this is better supported by microvm machine type
795-
baseargs = append(baseargs, "-netdev", "user,id=id1,hostfwd=tcp:"+cfg.SSHAddress+"-:22,hostfwd=tcp:"+cfg.SSHControlAddress+"-:2223")
795+
netdevArgs := "user,id=id1,hostfwd=tcp:" + cfg.SSHAddress + "-:22,hostfwd=tcp:" + cfg.SSHControlAddress + "-:2223"
796+
// QEMU_DNS_SEARCH allows configuring DNS search domains inside the guest VM.
797+
// This is useful for builds that need to resolve short hostnames via search
798+
// domains, or when the build environment requires specific DNS resolution
799+
// behavior. The search domains are passed to SLIRP which includes them in DHCP
800+
// responses, so the guest receives them naturally via DHCP.
801+
// Multiple domains should be comma-separated.
802+
// Example: QEMU_DNS_SEARCH="example.com,my.domain.org"
803+
if dnsSearch, ok := os.LookupEnv("QEMU_DNS_SEARCH"); ok {
804+
domains, err := parseDNSSearchDomains(dnsSearch)
805+
if err != nil {
806+
return fmt.Errorf("invalid QEMU_DNS_SEARCH value %q: %w", dnsSearch, err)
807+
}
808+
log.Infof("qemu: QEMU_DNS_SEARCH set to %v, adding %d domain(s) to SLIRP network config", domains, len(domains))
809+
netdevArgs += buildDNSSearchNetdevArgs(domains)
810+
}
811+
baseargs = append(baseargs, "-netdev", netdevArgs)
796812
// Set host_mtu to avoid silent packet drops in nested environments (e.g.,
797813
// QEMU inside GKE pods). SLIRP defaults to 1500 MTU but the host path MTU
798814
// may be lower due to encapsulation (GCP VPC uses 1460, pod networks can be
@@ -2330,6 +2346,65 @@ func getAdditionalPackages(ctx context.Context) []string {
23302346
return packages
23312347
}
23322348

2349+
// dnsSearchDomainRegex matches valid DNS search domain characters.
2350+
// Only allows alphanumeric characters, dots, and hyphens.
2351+
// This prevents injection of QEMU netdev options via malicious domain names.
2352+
var (
2353+
// dnsSearchDomainRegex matches valid DNS search domain characters.
2354+
// Only allows alphanumeric characters, dots, and hyphens.
2355+
// This prevents injection of QEMU netdev options via malicious domain names.
2356+
dnsSearchDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
2357+
)
2358+
2359+
// parseDNSSearchDomains parses and validates DNS search domains from a comma-separated string.
2360+
// Returns an error if the input is empty or contains invalid domain characters.
2361+
func parseDNSSearchDomains(input string) ([]string, error) {
2362+
input = strings.TrimSpace(input)
2363+
if input == "" {
2364+
return nil, fmt.Errorf("empty input")
2365+
}
2366+
2367+
// Only split on commas
2368+
parts := strings.Split(input, ",")
2369+
2370+
domains := make([]string, 0, len(parts))
2371+
for _, part := range parts {
2372+
part = strings.TrimSpace(part)
2373+
if part == "" {
2374+
continue
2375+
}
2376+
2377+
// Validate domain: only allow [a-zA-Z0-9.-]
2378+
if !dnsSearchDomainRegex.MatchString(part) {
2379+
return nil, fmt.Errorf("invalid characters in domain %q: only alphanumeric, dots, and hyphens are allowed", part)
2380+
}
2381+
2382+
domains = append(domains, part)
2383+
}
2384+
2385+
if len(domains) == 0 {
2386+
return nil, fmt.Errorf("no valid domains found")
2387+
}
2388+
2389+
return domains, nil
2390+
}
2391+
2392+
// buildDNSSearchNetdevArgs constructs the QEMU netdev dnssearch options string.
2393+
// Returns empty string if no domains provided.
2394+
// Each domain produces a separate ",dnssearch=<domain>" option.
2395+
func buildDNSSearchNetdevArgs(domains []string) string {
2396+
if len(domains) == 0 {
2397+
return ""
2398+
}
2399+
2400+
var builder strings.Builder
2401+
for _, domain := range domains {
2402+
builder.WriteString(",dnssearch=")
2403+
builder.WriteString(domain)
2404+
}
2405+
return builder.String()
2406+
}
2407+
23332408
// getPackageCacheSuffix generates a deterministic cache suffix based on the package list.
23342409
// Uses SHA256 hash (first 12 chars) to avoid collisions and keep filenames reasonable.
23352410
// Returns empty string if packages list is empty.

pkg/container/qemu_runner_test.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,267 @@ func TestGetAdditionalPackages(t *testing.T) {
231231
}
232232
}
233233

234+
func TestParseDNSSearchDomains(t *testing.T) {
235+
tests := []struct {
236+
name string
237+
input string
238+
expected []string
239+
wantErr bool
240+
}{
241+
// Valid single domain
242+
{
243+
name: "single valid domain",
244+
input: "example.com",
245+
expected: []string{"example.com"},
246+
},
247+
// Valid multiple domains - comma separated
248+
{
249+
name: "comma separated domains",
250+
input: "example.com,test.org",
251+
expected: []string{"example.com", "test.org"},
252+
},
253+
// Multiple commas collapsed
254+
{
255+
name: "multiple commas collapsed",
256+
input: "a.com,,b.org",
257+
expected: []string{"a.com", "b.org"},
258+
},
259+
// Comma with spaces around domains (trimmed)
260+
{
261+
name: "comma with spaces trimmed",
262+
input: "a.com, b.org , c.net",
263+
expected: []string{"a.com", "b.org", "c.net"},
264+
},
265+
// Hyphenated domain
266+
{
267+
name: "hyphenated domain",
268+
input: "my-domain.example.com",
269+
expected: []string{"my-domain.example.com"},
270+
},
271+
// Nested subdomains
272+
{
273+
name: "nested subdomains",
274+
input: "a.b.c.d.example.com",
275+
expected: []string{"a.b.c.d.example.com"},
276+
},
277+
// Numeric domain parts
278+
{
279+
name: "numeric domain parts",
280+
input: "123.example.com",
281+
expected: []string{"123.example.com"},
282+
},
283+
// Empty input
284+
{
285+
name: "empty string",
286+
input: "",
287+
wantErr: true,
288+
},
289+
// Only whitespace
290+
{
291+
name: "only whitespace",
292+
input: " ",
293+
wantErr: true,
294+
},
295+
// Only commas
296+
{
297+
name: "only commas",
298+
input: ",,,",
299+
wantErr: true,
300+
},
301+
// Space-separated domains (not allowed)
302+
{
303+
name: "space separated domains rejected",
304+
input: "example.com test.org",
305+
wantErr: true,
306+
},
307+
// Newline in domain (not allowed)
308+
{
309+
name: "newline rejected",
310+
input: "foo\nbar",
311+
wantErr: true,
312+
},
313+
// Tab in domain (not allowed)
314+
{
315+
name: "tab rejected",
316+
input: "foo\tbar",
317+
wantErr: true,
318+
},
319+
// Injection with equals sign (netdev option injection)
320+
{
321+
name: "injection attempt with equals",
322+
input: "evil=value",
323+
wantErr: true,
324+
},
325+
// Injection with hostfwd attempt
326+
{
327+
name: "hostfwd injection attempt",
328+
input: "foo,hostfwd=tcp::8080-:22",
329+
wantErr: true,
330+
},
331+
// Injection with colon
332+
{
333+
name: "colon injection (port-like)",
334+
input: "domain:8080",
335+
wantErr: true,
336+
},
337+
// Semicolon injection (command separator)
338+
{
339+
name: "semicolon injection",
340+
input: "foo;rm -rf /",
341+
wantErr: true,
342+
},
343+
// Pipe injection
344+
{
345+
name: "pipe injection",
346+
input: "foo|cat /etc/passwd",
347+
wantErr: true,
348+
},
349+
// Backtick injection
350+
{
351+
name: "backtick injection",
352+
input: "foo`whoami`",
353+
wantErr: true,
354+
},
355+
// Dollar sign injection
356+
{
357+
name: "dollar sign injection",
358+
input: "foo$HOME",
359+
wantErr: true,
360+
},
361+
// Quote injection
362+
{
363+
name: "double quote injection",
364+
input: `foo"bar`,
365+
wantErr: true,
366+
},
367+
// Single quote injection
368+
{
369+
name: "single quote injection",
370+
input: "foo'bar",
371+
wantErr: true,
372+
},
373+
// Ampersand injection
374+
{
375+
name: "ampersand injection",
376+
input: "foo&bar",
377+
wantErr: true,
378+
},
379+
// Parentheses injection
380+
{
381+
name: "parentheses injection",
382+
input: "foo(bar)",
383+
wantErr: true,
384+
},
385+
// Bracket injection
386+
{
387+
name: "bracket injection",
388+
input: "foo[bar]",
389+
wantErr: true,
390+
},
391+
// Brace injection
392+
{
393+
name: "brace injection",
394+
input: "foo{bar}",
395+
wantErr: true,
396+
},
397+
// Angle bracket injection
398+
{
399+
name: "angle bracket injection",
400+
input: "foo<bar>",
401+
wantErr: true,
402+
},
403+
// Backslash injection
404+
{
405+
name: "backslash injection",
406+
input: "foo\\bar",
407+
wantErr: true,
408+
},
409+
// Forward slash (path-like)
410+
{
411+
name: "forward slash injection",
412+
input: "foo/bar",
413+
wantErr: true,
414+
},
415+
// One valid, one invalid domain
416+
{
417+
name: "mixed valid and invalid domains",
418+
input: "good.com,evil=bad",
419+
wantErr: true,
420+
},
421+
// QEMU dnssearch option injection attempt
422+
{
423+
name: "dnssearch option injection",
424+
input: "foo,dnssearch=evil.com",
425+
wantErr: true,
426+
},
427+
}
428+
429+
for _, tt := range tests {
430+
t.Run(tt.name, func(t *testing.T) {
431+
result, err := parseDNSSearchDomains(tt.input)
432+
433+
if tt.wantErr {
434+
if err == nil {
435+
t.Errorf("parseDNSSearchDomains(%q) expected error, got nil with result %v", tt.input, result)
436+
}
437+
return
438+
}
439+
440+
if err != nil {
441+
t.Errorf("parseDNSSearchDomains(%q) unexpected %v", tt.input, err)
442+
return
443+
}
444+
445+
if len(result) != len(tt.expected) {
446+
t.Errorf("parseDNSSearchDomains(%q) returned %d domains, expected %d: got %v, want %v",
447+
tt.input, len(result), len(tt.expected), result, tt.expected)
448+
return
449+
}
450+
451+
for i, domain := range result {
452+
if domain != tt.expected[i] {
453+
t.Errorf("parseDNSSearchDomains(%q)[%d] = %q, expected %q",
454+
tt.input, i, domain, tt.expected[i])
455+
}
456+
}
457+
})
458+
}
459+
}
460+
461+
func TestBuildDNSSearchNetdevArgs(t *testing.T) {
462+
tests := []struct {
463+
name string
464+
domains []string
465+
expected string
466+
}{
467+
{
468+
name: "empty domains",
469+
domains: nil,
470+
expected: "",
471+
},
472+
{
473+
name: "single domain",
474+
domains: []string{"example.com"},
475+
expected: ",dnssearch=example.com",
476+
},
477+
{
478+
name: "multiple domains",
479+
domains: []string{"a.com", "b.org", "c.net"},
480+
expected: ",dnssearch=a.com,dnssearch=b.org,dnssearch=c.net",
481+
},
482+
}
483+
484+
for _, tt := range tests {
485+
t.Run(tt.name, func(t *testing.T) {
486+
result := buildDNSSearchNetdevArgs(tt.domains)
487+
if result != tt.expected {
488+
t.Errorf("buildDNSSearchNetdevArgs(%v) = %q, expected %q",
489+
tt.domains, result, tt.expected)
490+
}
491+
})
492+
}
493+
}
494+
234495
func TestGetPackageCacheSuffix(t *testing.T) {
235496
tests := []struct {
236497
name string

0 commit comments

Comments
 (0)