From 6eb3827dddd02ef02dfce19cd613e1db3ea5d580 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sat, 25 Jul 2026 22:00:02 +0100 Subject: [PATCH] feat: dnsname argument type --- docs/modules/ROOT/pages/args/types.adoc | 1 + frontend/resources/vue/views/ArgumentForm.vue | 2 +- service/internal/executor/arguments.go | 32 ++++++++++++++++ service/internal/executor/arguments_test.go | 38 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/args/types.adoc b/docs/modules/ROOT/pages/args/types.adoc index 6d6f6877..272db8e8 100644 --- a/docs/modules/ROOT/pages/args/types.adoc +++ b/docs/modules/ROOT/pages/args/types.adoc @@ -10,6 +10,7 @@ A full list of argument types are below; | (default) | xref:args/input.adoc[Textbox] | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults. | ascii | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, but no spaces or punctuation | ascii_identifier | xref:args/input.adoc[Textbox] | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. +| dnsname | xref:args/input.adoc[Textbox] | A DNS hostname (RFC 1123). Short names (e.g. `webserver`) and FQDNs (e.g. `webserver.example.com`). Letters/digits/hyphens only, no underscores. Optional trailing dot allowed. | shell_safe_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers. | ascii_sentence | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, with spaces, `.` and `,`. | unicode_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc. diff --git a/frontend/resources/vue/views/ArgumentForm.vue b/frontend/resources/vue/views/ArgumentForm.vue index 0588358a..9da632c7 100644 --- a/frontend/resources/vue/views/ArgumentForm.vue +++ b/frontend/resources/vue/views/ArgumentForm.vue @@ -291,7 +291,7 @@ function getInputType (arg) { return 'checkbox' } - if (arg.type === 'ascii_identifier' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') { + if (arg.type === 'ascii_identifier' || arg.type === 'dnsname' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') { return 'text' } diff --git a/service/internal/executor/arguments.go b/service/internal/executor/arguments.go index 6810e6c2..e9ff778f 100644 --- a/service/internal/executor/arguments.go +++ b/service/internal/executor/arguments.go @@ -24,6 +24,9 @@ var ( "shell_safe_identifier": `^[a-zA-Z0-9@\.\_\+\-]+$`, "ascii_sentence": `^[a-zA-Z0-9\-\._, ]+$`, } + + dnsNameLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + dnsNameAllNumericPattern = regexp.MustCompile(`^[0-9]+$`) ) // parseExecArray parses all exec arguments in the action. @@ -223,6 +226,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error { return typeSafetyCheckUrl(value) case "datetime": return typeSafetyCheckDatetime(value) + case "dnsname": + return typeSafetyCheckDnsName(value) } return typeSafetyCheckRegex(name, value, argumentType) @@ -304,6 +309,33 @@ func typeSafetyCheckEmail(value string) error { return nil } +// typeSafetyCheckDnsName validates a DNS hostname (RFC 1123 LDH labels). +// Accepts short names (e.g. webserver) and FQDNs (e.g. webserver.example.com). +// An optional trailing dot is allowed. +func typeSafetyCheckDnsName(value string) error { + hostname := strings.TrimSuffix(value, ".") + if hostname == "" || len(hostname) > 253 { + return fmt.Errorf("invalid dnsname length") + } + + return typeSafetyCheckDnsNameLabels(strings.Split(hostname, ".")) +} + +func typeSafetyCheckDnsNameLabels(labels []string) error { + for _, label := range labels { + if !dnsNameLabelPattern.MatchString(label) { + return fmt.Errorf("invalid dnsname label %q", label) + } + } + + tld := labels[len(labels)-1] + if dnsNameAllNumericPattern.MatchString(tld) { + return fmt.Errorf("dnsname top-level label must not be all-numeric") + } + + return nil +} + func typeSafetyCheckDatetime(value string) error { _, err := time.Parse("2006-01-02T15:04:05", value) diff --git a/service/internal/executor/arguments_test.go b/service/internal/executor/arguments_test.go index d6caffe0..6d286269 100644 --- a/service/internal/executor/arguments_test.go +++ b/service/internal/executor/arguments_test.go @@ -812,6 +812,44 @@ func TestTypeSafetyCheckAsciiIdentifier(t *testing.T) { } } +func TestTypeSafetyCheckDnsName(t *testing.T) { + tests := []struct { + name string + value string + hasError bool + }{ + {"Short name", "webserver", false}, + {"Localhost", "localhost", false}, + {"Simple domain", "example.com", false}, + {"Host with subdomain", "webserver.example.com", false}, + {"Deep subdomain", "a.b.c.example.co.uk", false}, + {"Label starting with digit", "1host.example.com", false}, + {"Trailing dot", "example.com.", false}, + {"Punycode IDN", "xn--bcher-kva.example", false}, + {"Underscore", "my_host.example.com", true}, + {"Space", "example .com", true}, + {"Leading hyphen label", "-host.example.com", true}, + {"Trailing hyphen label", "host-.example.com", true}, + {"Empty label", "example..com", true}, + {"IP address", "192.168.1.1", true}, + {"All numeric TLD", "example.123", true}, + {"All numeric short name", "12345", true}, + {"Special chars", "exam!ple.com", true}, + {"Unicode label", "bücher.example.com", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := TypeSafetyCheck("host", tt.value, "dnsname") + if tt.hasError { + assert.NotNil(t, err, "Expected error for value '%s'", tt.value) + } else { + assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) + } + }) + } +} + func TestTypeSafetyCheckShellSafeIdentifier(t *testing.T) { tests := []struct { name string