diff --git a/api/resource/definitions/network/network.proto b/api/resource/definitions/network/network.proto
index 083e95620eb..b9575e5d50b 100755
--- a/api/resource/definitions/network/network.proto
+++ b/api/resource/definitions/network/network.proto
@@ -96,14 +96,14 @@ message HardwareAddrSpec {
bytes hardware_addr = 2;
}
-// HostnameSpecSpec describes node nostname.
+// HostnameSpecSpec describes node hostname.
message HostnameSpecSpec {
string hostname = 1;
string domainname = 2;
talos.resource.definitions.enums.NetworkConfigLayer config_layer = 3;
}
-// HostnameStatusSpec describes node nostname.
+// HostnameStatusSpec describes node hostname.
message HostnameStatusSpec {
string hostname = 1;
string domainname = 2;
diff --git a/go.mod b/go.mod
index f0962c5a840..d25f8e7ad23 100644
--- a/go.mod
+++ b/go.mod
@@ -65,7 +65,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.6.0
github.com/hetznercloud/hcloud-go v1.41.0
- github.com/insomniacslk/dhcp v0.0.0-20230307103557-e252950ab961
+ github.com/insomniacslk/dhcp v0.0.0-20230327135226-74ae03f2425e
github.com/jsimonetti/rtnetlink v1.3.1
github.com/jxskiss/base62 v1.1.0
github.com/martinlindhe/base36 v1.1.1
diff --git a/go.sum b/go.sum
index 46f46538e10..a03281c46e6 100644
--- a/go.sum
+++ b/go.sum
@@ -813,8 +813,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/insomniacslk/dhcp v0.0.0-20230307103557-e252950ab961 h1:x/YtdDlmypenG1te/FfH6LVM+3krhXk5CFV8VYNNX5M=
-github.com/insomniacslk/dhcp v0.0.0-20230307103557-e252950ab961/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI=
+github.com/insomniacslk/dhcp v0.0.0-20230327135226-74ae03f2425e h1:8ChxkWKTVYg7LKBvYNLNRnlobgbPrzzossZUoST2T7o=
+github.com/insomniacslk/dhcp v0.0.0-20230327135226-74ae03f2425e/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI=
github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
diff --git a/internal/app/machined/pkg/controllers/network/operator/dhcp4.go b/internal/app/machined/pkg/controllers/network/operator/dhcp4.go
index e0fff4eb0fb..4ae5a10b0ef 100644
--- a/internal/app/machined/pkg/controllers/network/operator/dhcp4.go
+++ b/internal/app/machined/pkg/controllers/network/operator/dhcp4.go
@@ -14,12 +14,15 @@ import (
"sync"
"time"
+ "github.com/cosi-project/runtime/pkg/resource"
+ "github.com/cosi-project/runtime/pkg/state"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/siderolabs/gen/slices"
"go.uber.org/zap"
"go4.org/netipx"
+ "github.com/siderolabs/talos/internal/app/machined/pkg/controllers/network/operator/utils"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
"github.com/siderolabs/talos/pkg/machinery/nethelpers"
"github.com/siderolabs/talos/pkg/machinery/resources/network"
@@ -28,13 +31,14 @@ import (
// DHCP4 implements the DHCPv4 network operator.
type DHCP4 struct {
logger *zap.Logger
+ state state.State
linkName string
routeMetric uint32
skipHostnameRequest bool
requestMTU bool
- offer *dhcpv4.DHCPv4
+ lease *nclient4.Lease
mu sync.Mutex
addresses []network.AddressSpecSpec
@@ -46,9 +50,10 @@ type DHCP4 struct {
}
// NewDHCP4 creates DHCPv4 operator.
-func NewDHCP4(logger *zap.Logger, linkName string, config network.DHCP4OperatorSpec, platform runtime.Platform) *DHCP4 {
+func NewDHCP4(logger *zap.Logger, linkName string, config network.DHCP4OperatorSpec, platform runtime.Platform, state state.State) *DHCP4 {
return &DHCP4{
logger: logger,
+ state: state,
linkName: linkName,
routeMetric: config.RouteMetric,
skipHostnameRequest: config.SkipHostnameRequest,
@@ -64,21 +69,130 @@ func (d *DHCP4) Prefix() string {
return fmt.Sprintf("dhcp4/%s", d.linkName)
}
+// extractHostname extracts a hostname from the given resource if it is a valid network.HostnameStatus.
+func extractHostname(res resource.Resource) string {
+ if res, ok := res.(*network.HostnameStatus); ok {
+ return res.TypedSpec().Hostname
+ }
+
+ return ""
+}
+
+// setupHostnameWatch returns the initial hostname and a channel that outputs all events related to hostname changes.
+func (d *DHCP4) setupHostnameWatch(ctx context.Context) (string, <-chan state.Event, error) {
+ hostnameWatchCh := make(chan state.Event)
+ if err := d.state.Watch(ctx, resource.NewMetadata(
+ network.NamespaceName,
+ network.HostnameStatusType,
+ network.HostnameID,
+ resource.VersionUndefined,
+ ), hostnameWatchCh); err != nil {
+ return "", nil, err
+ }
+
+ return extractHostname((<-hostnameWatchCh).Resource), hostnameWatchCh, nil
+}
+
+// knownHostname checks if the given hostname has been defined by this operator.
+func (d *DHCP4) knownHostname(hostname string) bool {
+ for i := range d.hostname {
+ if d.hostname[i].Hostname == hostname {
+ return true
+ }
+ }
+
+ return false
+}
+
+// waitForNetworkReady waits for the network to be ready and the leased address to
+// be assigned to the associated so that unicast operations can bind successfully.
+func (d *DHCP4) waitForNetworkReady(ctx context.Context) error {
+ // If an IP address has been registered, wait for the address association to be ready
+ if len(d.addresses) > 0 {
+ _, err := d.state.WatchFor(ctx,
+ resource.NewMetadata(
+ network.NamespaceName,
+ network.AddressStatusType,
+ network.AddressID(d.linkName, d.addresses[0].Address),
+ resource.VersionUndefined,
+ ),
+ state.WithPhases(resource.PhaseRunning),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to wait for the address association to be ready: %w", err)
+ }
+ }
+
+ // Wait for the network (address and connectivity) to be ready
+ if err := network.NewReadyCondition(d.state, network.AddressReady, network.ConnectivityReady).Wait(ctx); err != nil {
+ return fmt.Errorf("failed to wait for the network address and connectivity to be ready: %w", err)
+ }
+
+ return nil
+}
+
// Run the operator loop.
//
-//nolint:gocyclo,dupl
+//nolint:gocyclo,cyclop,dupl
func (d *DHCP4) Run(ctx context.Context, notifyCh chan<- struct{}) {
- const minRenewDuration = 5 * time.Second // protect from renewing too often
+ const minRenewDuration = 5 * time.Second // Protect from renewing too often
renewInterval := minRenewDuration
+ hostname, hostnameWatchCh, err := d.setupHostnameWatch(ctx)
+ if err != nil && !errors.Is(err, context.Canceled) {
+ d.logger.Warn("failed to watch for hostname changes", zap.Error(err))
+ }
+
for {
- leaseTime, err := d.renew(ctx)
+ // Track if we need to acquire a new lease
+ newLease := d.lease == nil
+
+ // Perform a lease request or renewal
+ leaseTime, err := d.requestRenew(ctx, hostname)
if err != nil && !errors.Is(err, context.Canceled) {
- d.logger.Warn("renew failed", zap.Error(err), zap.String("link", d.linkName))
+ d.logger.Warn("request/renew failed", zap.Error(err), zap.String("link", d.linkName))
}
- if err == nil {
+ if err == nil && newLease {
+ // Notify the underlying controller about the new lease
+ select {
+ case notifyCh <- struct{}{}:
+ case <-ctx.Done():
+ return
+ }
+
+ if !d.skipHostnameRequest {
+ // Wait for networking to be established before transitioning to unicast operations
+ if err = d.waitForNetworkReady(ctx); err != nil && !errors.Is(err, context.Canceled) {
+ d.logger.Warn("failed to wait for networking to become ready", zap.Error(err))
+ }
+ }
+ }
+
+ // DHCP hostname parroting protection: if, e.g., `dnsmasq` receives a request that both
+ // sends a hostname and requests one, it will "parrot" the sent hostname back if no other
+ // name has been defined for the requesting host. This causes update anomalies, since
+ // removing a hostname defined previously by, e.g., the configuration layer, causes a copy
+ // of that hostname to live on in a spec defined by this operator, even though it isn't
+ // sourced from DHCP.
+ //
+ // To avoid this issue, never send and request a hostname in the same operation. When
+ // negotiating a new lease, first send the current hostname when acquiring the lease, and
+ // then follow up with a dedicated INFORM request asking the server for a DHCP-defined
+ // hostname. When renewing a lease, we're free to always request a hostname with an INFORM
+ // (to detect server-side changes), since any changes to the node hostname will cause a
+ // lease invalidation and re-start the negotiation process. More details below.
+ if err == nil && !d.skipHostnameRequest {
+ // Request the node hostname from the DHCP server
+ err = d.requestHostname(ctx)
+ if err != nil && !errors.Is(err, context.Canceled) {
+ d.logger.Warn("hostname request failed", zap.Error(err), zap.String("link", d.linkName))
+ }
+ }
+
+ // Notify the underlying controller about the received hostname and/or renewed lease
+ if err == nil && (!d.skipHostnameRequest || !newLease) {
select {
case notifyCh <- struct{}{}:
case <-ctx.Done():
@@ -96,10 +210,39 @@ func (d *DHCP4) Run(ctx context.Context, notifyCh chan<- struct{}) {
renewInterval = minRenewDuration
}
- select {
- case <-ctx.Done():
- return
- case <-time.After(renewInterval):
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(renewInterval):
+ case event := <-hostnameWatchCh:
+ // If the hostname resource was deleted entirely, we must still inform the DHCP
+ // server that the node has no hostname anymore. `extractHostname` will return a
+ // blank hostname for a Tombstone resource generated by a deletion event.
+ hostname = extractHostname(event.Resource)
+
+ // If, on first invocation, the DHCP server has given a new hostname for the node,
+ // and the `network.HostnameSpecController` decides to apply it as a preferred
+ // hostname, this operator would unnecessarily drop the lease and restart DHCP
+ // discovery. Thus, if the selected hostname has been sourced from this operator,
+ // we don't need to do anything.
+ if d.knownHostname(hostname) {
+ continue
+ }
+
+ // While updating the hostname together with a RENEW request works with dnsmasq, it
+ // doesn't work with the Windows Server DHCP + DNS. A hostname update via an
+ // INIT-REBOOT request also gets ignored. Thus, the only reliable way to update the
+ // hostname seems to be to forget the old release and initiate a new DISCOVER flow
+ // with the new hostname. RFC 2131 doesn't define any better way to do this, and,
+ // as a DISCOVER request cannot be targeted at the previous lessor according to the
+ // spec, the node may switch DHCP servers on hostname change. However, this is not
+ // a major concern, since a single network should not host multiple competing DHCP
+ // servers in the first place.
+ d.lease = nil
+ }
+
+ break
}
}
}
@@ -152,8 +295,30 @@ func (d *DHCP4) TimeServerSpecs() []network.TimeServerSpecSpec {
return d.timeservers
}
+func (d *DHCP4) parseHostnameFromAck(ack *dhcpv4.DHCPv4) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ d.hostname = nil
+ if ack.HostName() != "" {
+ spec := network.HostnameSpecSpec{
+ ConfigLayer: network.ConfigOperator,
+ }
+
+ if err := spec.ParseFQDN(ack.HostName()); err == nil {
+ if ack.DomainName() != "" {
+ spec.Domainname = ack.DomainName()
+ }
+
+ d.hostname = []network.HostnameSpecSpec{
+ spec,
+ }
+ }
+ }
+}
+
//nolint:gocyclo
-func (d *DHCP4) parseAck(ack *dhcpv4.DHCPv4) {
+func (d *DHCP4) parseNetworkConfigFromAck(ack *dhcpv4.DHCPv4) {
d.mu.Lock()
defer d.mu.Unlock()
@@ -228,7 +393,7 @@ func (d *DHCP4) parseAck(ack *dhcpv4.DHCPv4) {
})
if !addr.Contains(gw) {
- // add an interface route for the gateway if it's not in the same network
+ // Add an interface route for the gateway if it's not in the same network
d.routes = append(d.routes, network.RouteSpecSpec{
Family: nethelpers.FamilyInet4,
Destination: netip.PrefixFrom(gw, gw.BitLen()),
@@ -266,26 +431,6 @@ func (d *DHCP4) parseAck(ack *dhcpv4.DHCPv4) {
d.resolvers = nil
}
- if ack.HostName() != "" && !d.skipHostnameRequest {
- spec := network.HostnameSpecSpec{
- ConfigLayer: network.ConfigOperator,
- }
-
- if err = spec.ParseFQDN(ack.HostName()); err == nil {
- if ack.DomainName() != "" {
- spec.Domainname = ack.DomainName()
- }
-
- d.hostname = []network.HostnameSpecSpec{
- spec,
- }
- } else {
- d.hostname = nil
- }
- } else {
- d.hostname = nil
- }
-
if len(ack.NTPServers()) > 0 {
convertIP := func(ip net.IP) string {
result, _ := netipx.FromStdIP(ip)
@@ -304,68 +449,118 @@ func (d *DHCP4) parseAck(ack *dhcpv4.DHCPv4) {
}
}
-func (d *DHCP4) renew(ctx context.Context) (time.Duration, error) {
+func (d *DHCP4) newClient() (*nclient4.Client, error) {
+ var clientOpts []nclient4.ClientOpt
+
+ // We have an existing lease, target the server with unicast
+ if d.lease != nil {
+ serverAddr, err := utils.ToUDPAddr(d.lease.ACK.ServerIPAddr, nclient4.ServerPort)
+ if err != nil {
+ return nil, err
+ }
+
+ clientAddr, err := utils.ToUDPAddr(d.lease.ACK.YourIPAddr, nclient4.ClientPort)
+ if err != nil {
+ return nil, err
+ }
+
+ // RFC 2131, section 4.3.2:
+ // DHCPREQUEST generated during RENEWING state:
+ // ... This message will be unicast, so no relay
+ // agents will be involved in its transmission.
+ clientOpts = append(clientOpts,
+ nclient4.WithServerAddr(serverAddr),
+ // WithUnicast must be specified manually, WithServerAddr is not enough
+ nclient4.WithUnicast(clientAddr),
+ )
+ }
+
+ // Create a new client, the caller is responsible for closing it
+ return nclient4.New(d.linkName, clientOpts...)
+}
+
+// requestHostname uses an INFORM request to request a hostname from the DHCP server, as requesting
+// it during a DISCOVER, when simultaneously sending the local hostname, is not reliable (see above).
+func (d *DHCP4) requestHostname(ctx context.Context) error {
+ opts := []dhcpv4.OptionCode{
+ dhcpv4.OptionHostName,
+ dhcpv4.OptionDomainName,
+ }
+
+ client, err := d.newClient()
+ if err != nil {
+ return err
+ }
+
+ //nolint:errcheck
+ defer client.Close()
+
+ d.logger.Debug("DHCP INFORM", zap.String("link", d.linkName))
+
+ // Send an INFORM request for querying a hostname from the DHCP server
+ ack, err := client.Inform(ctx, d.lease.ACK.YourIPAddr, dhcpv4.WithRequestedOptions(opts...))
+ if err != nil {
+ return err
+ }
+
+ d.logger.Debug("DHCP ACK", zap.String("link", d.linkName), zap.String("dhcp", collapseSummary(ack.Summary())))
+
+ // Parse the hostname from the response
+ d.parseHostnameFromAck(ack)
+
+ return nil
+}
+
+func (d *DHCP4) requestRenew(ctx context.Context, hostname string) (time.Duration, error) {
opts := []dhcpv4.OptionCode{
dhcpv4.OptionClasslessStaticRoute,
dhcpv4.OptionDomainNameServer,
+ // TODO(twelho): This is unused until network.ResolverSpec supports search domains
dhcpv4.OptionDNSDomainSearchList,
dhcpv4.OptionNTPServers,
dhcpv4.OptionDomainName,
}
- if !d.skipHostnameRequest {
- opts = append(opts, dhcpv4.OptionHostName)
- }
-
if d.requestMTU {
opts = append(opts, dhcpv4.OptionInterfaceMTU)
}
mods := []dhcpv4.Modifier{dhcpv4.WithRequestedOptions(opts...)}
- clientOpts := []nclient4.ClientOpt{}
-
- if d.offer != nil {
- // do not use broadcast, but send the packet to DHCP server directly
- addr, err := net.ResolveUDPAddr("udp", d.offer.ServerIPAddr.String()+":67")
- if err != nil {
- return 0, err
- }
-
- // by default it's set to 0.0.0.0 which actually breaks lease renew
- d.offer.ClientIPAddr = d.offer.YourIPAddr
- clientOpts = append(clientOpts, nclient4.WithServerAddr(addr))
+ // If the node has a hostname, always send it to the DHCP
+ // server with option 12 during lease acquisition and renewal
+ if len(hostname) > 0 {
+ mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(hostname)))
}
- cli, err := nclient4.New(d.linkName, clientOpts...)
+ client, err := d.newClient()
if err != nil {
return 0, err
}
//nolint:errcheck
- defer cli.Close()
-
- var lease *nclient4.Lease
+ defer client.Close()
- if d.offer != nil {
- lease, err = cli.RequestFromOffer(ctx, d.offer, mods...)
+ if d.lease != nil {
+ d.logger.Debug("DHCP RENEW", zap.String("link", d.linkName))
+ d.lease, err = client.Renew(ctx, d.lease, mods...)
} else {
- lease, err = cli.Request(ctx, mods...)
+ d.logger.Debug("DHCP REQUEST", zap.String("link", d.linkName))
+ d.lease, err = client.Request(ctx, mods...)
}
if err != nil {
- // clear offer if request fails to start with discover sequence next time
- d.offer = nil
+ // explicitly clear the lease on failure to start with the discovery sequence next time
+ d.lease = nil
return 0, err
}
- d.logger.Debug("DHCP ACK", zap.String("link", d.linkName), zap.String("dhcp", collapseSummary(lease.ACK.Summary())))
+ d.logger.Debug("DHCP ACK", zap.String("link", d.linkName), zap.String("dhcp", collapseSummary(d.lease.ACK.Summary())))
- d.offer = lease.Offer
- d.parseAck(lease.ACK)
+ d.parseNetworkConfigFromAck(d.lease.ACK)
- return lease.ACK.IPAddressLeaseTime(time.Minute * 30), nil
+ return d.lease.ACK.IPAddressLeaseTime(time.Minute * 30), nil
}
func collapseSummary(summary string) string {
diff --git a/internal/app/machined/pkg/controllers/network/operator/utils/utils.go b/internal/app/machined/pkg/controllers/network/operator/utils/utils.go
new file mode 100644
index 00000000000..29e00f45ad2
--- /dev/null
+++ b/internal/app/machined/pkg/controllers/network/operator/utils/utils.go
@@ -0,0 +1,22 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+// Package utils provides common methods for operators.
+package utils
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+)
+
+// ToUDPAddr combines the given net.IP and port to form a net.UDPAddr.
+func ToUDPAddr(ip net.IP, port uint16) (*net.UDPAddr, error) {
+ addr, ok := netip.AddrFromSlice(ip)
+ if !ok {
+ return nil, fmt.Errorf("failed to parse %q as an IP address", ip)
+ }
+
+ return net.UDPAddrFromAddrPort(netip.AddrPortFrom(addr, port)), nil
+}
diff --git a/internal/app/machined/pkg/controllers/network/operator_spec.go b/internal/app/machined/pkg/controllers/network/operator_spec.go
index 984e79538ba..22469d9cb12 100644
--- a/internal/app/machined/pkg/controllers/network/operator_spec.go
+++ b/internal/app/machined/pkg/controllers/network/operator_spec.go
@@ -423,7 +423,7 @@ func (ctrl *OperatorSpecController) newOperator(logger *zap.Logger, spec *networ
case network.OperatorDHCP4:
logger = logger.With(zap.String("operator", "dhcp4"))
- return operator.NewDHCP4(logger, spec.LinkName, spec.DHCP4, ctrl.V1alpha1Platform)
+ return operator.NewDHCP4(logger, spec.LinkName, spec.DHCP4, ctrl.V1alpha1Platform, ctrl.State)
case network.OperatorDHCP6:
logger = logger.With(zap.String("operator", "dhcp6"))
diff --git a/pkg/machinery/api/resource/definitions/network/network.pb.go b/pkg/machinery/api/resource/definitions/network/network.pb.go
index e4e32e6c4dd..43f5e3d9093 100644
--- a/pkg/machinery/api/resource/definitions/network/network.pb.go
+++ b/pkg/machinery/api/resource/definitions/network/network.pb.go
@@ -801,7 +801,7 @@ func (x *HardwareAddrSpec) GetHardwareAddr() []byte {
return nil
}
-// HostnameSpecSpec describes node nostname.
+// HostnameSpecSpec describes node hostname.
type HostnameSpecSpec struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -865,7 +865,7 @@ func (x *HostnameSpecSpec) GetConfigLayer() enums.NetworkConfigLayer {
return enums.NetworkConfigLayer(0)
}
-// HostnameStatusSpec describes node nostname.
+// HostnameStatusSpec describes node hostname.
type HostnameStatusSpec struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
diff --git a/pkg/machinery/resources/network/condition.go b/pkg/machinery/resources/network/condition.go
index e12c5169ad6..93a691c0836 100644
--- a/pkg/machinery/resources/network/condition.go
+++ b/pkg/machinery/resources/network/condition.go
@@ -19,7 +19,7 @@ type ReadyCondition struct {
checks []StatusCheck
}
-// NewReadyCondition builds a coondition which waits for the network to be ready.
+// NewReadyCondition builds a condition which waits for the network to be ready.
func NewReadyCondition(state state.State, checks ...StatusCheck) *ReadyCondition {
return &ReadyCondition{
state: state,
diff --git a/pkg/machinery/resources/network/hostname_spec.go b/pkg/machinery/resources/network/hostname_spec.go
index d9ba8555786..4776e354430 100644
--- a/pkg/machinery/resources/network/hostname_spec.go
+++ b/pkg/machinery/resources/network/hostname_spec.go
@@ -25,7 +25,7 @@ type HostnameSpec = typed.Resource[HostnameSpecSpec, HostnameSpecExtension]
// HostnameID is the ID of the singleton instance.
const HostnameID resource.ID = "hostname"
-// HostnameSpecSpec describes node nostname.
+// HostnameSpecSpec describes node hostname.
//
//gotagsrewrite:gen
type HostnameSpecSpec struct {
diff --git a/pkg/machinery/resources/network/hostname_status.go b/pkg/machinery/resources/network/hostname_status.go
index 9dcf3236e6e..f35d7335a1c 100644
--- a/pkg/machinery/resources/network/hostname_status.go
+++ b/pkg/machinery/resources/network/hostname_status.go
@@ -19,7 +19,7 @@ const HostnameStatusType = resource.Type("HostnameStatuses.net.talos.dev")
// HostnameStatus resource holds node hostname.
type HostnameStatus = typed.Resource[HostnameStatusSpec, HostnameStatusExtension]
-// HostnameStatusSpec describes node nostname.
+// HostnameStatusSpec describes node hostname.
//
//gotagsrewrite:gen
type HostnameStatusSpec struct {
diff --git a/website/content/v1.4/reference/api.md b/website/content/v1.4/reference/api.md
index 78bd02a047c..6b06aadba2d 100644
--- a/website/content/v1.4/reference/api.md
+++ b/website/content/v1.4/reference/api.md
@@ -2567,7 +2567,7 @@ HardwareAddrSpec describes spec for the link.
### HostnameSpecSpec
-HostnameSpecSpec describes node nostname.
+HostnameSpecSpec describes node hostname.
| Field | Type | Label | Description |
@@ -2584,7 +2584,7 @@ HostnameSpecSpec describes node nostname.
### HostnameStatusSpec
-HostnameStatusSpec describes node nostname.
+HostnameStatusSpec describes node hostname.
| Field | Type | Label | Description |