]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
hostname: add $ hostname substitution and petnames
authorMichael Vogt <michael@amutable.com>
Wed, 3 Jun 2026 15:05:55 +0000 (17:05 +0200)
committerMichael Vogt <michael@amutable.com>
Sat, 20 Jun 2026 12:26:41 +0000 (14:26 +0200)
This commit adds support to /etc/hostname for substitution
of $ wordlists from {/etc,/run,/usr/lib}/systemd/hostname-wordlist.
The first $ will lookup hostname-wordlist/1, the next
hostname-wordlist/2 and so on.

With that we can do a petname [1] style hostname in systemd, e.g.
below a possible expansion for a hostname template:

    $-$-$-????  ->  wildly-happy-octopus-92a9

The substitution of words is stable (based on machine-id) but
not persisted, it is picked on every boot via a stable file
offset so the operation is cheap. But this means that if the
wordlist changes the hostname would change. The next commit
will add the pattern to the firstboot.hostname credential which
is persistet with the resolved names to avoid this issue.

This also includes a wordlist from the "petname" project
that can be optionally installed.

Thanks to Dustin Kirkland for this wonderful project.

[1] https://github.com/dustinkirkland/petname

16 files changed:
docs/ENVIRONMENT.md
hostname-wordlist/README [new file with mode: 0644]
hostname-wordlist/adjectives [new file with mode: 0644]
hostname-wordlist/adverbs [new file with mode: 0644]
hostname-wordlist/meson.build [new file with mode: 0644]
hostname-wordlist/nouns [new file with mode: 0644]
man/hostname.xml
meson.build
meson_options.txt
src/basic/hostname-util.c
src/basic/hostname-util.h
src/hostname/hostnamed.c
src/shared/hostname-setup.c
src/shared/hostname-setup.h
src/test/test-hostname-setup.c
test/units/TEST-71-HOSTNAME.sh

index a55e91ff203d206e415d566d5ca42d361d3cadaa..b528fce37da4747ad97d97b68866597c8c445527 100644 (file)
@@ -80,6 +80,11 @@ All tools:
   (relevant in particular for the system manager and `systemd-hostnamed`).
   Must be a valid hostname (either a single label or a FQDN).
 
+* `$SYSTEMD_HOSTNAME_WORDLIST_PATH` — search this directory for the numbered
+  hostname word list files used by the `$` wildcard in hostname patterns (see
+  `hostname(5)`), instead of the built-in search path. Only useful for
+  debugging and testing.
+
 * `$SD_EVENT_PROFILE_DELAYS=1` — if set, the sd-event event loop implementation
   will print latency information at runtime.
 
diff --git a/hostname-wordlist/README b/hostname-wordlist/README
new file mode 100644 (file)
index 0000000..1cda8ab
--- /dev/null
@@ -0,0 +1,68 @@
+Hostname word lists
+====================
+
+These files provide the word lists for the "$" wildcard understood by
+/etc/hostname (see hostname(5)). The "$" token is positional: the n-th "$" in a
+template is replaced by a word from the list file named "n", i.e. the first "$"
+uses the file "1", the second "2", and so on. A template such as:
+
+    $-$-$-???? ->  wildly-happy-octopus-92a9
+
+is expanded deterministically from the machine ID, so a given machine always
+gets the same name.
+
+The numbered files are shipped as symlinks to the semantic lists, so the same
+words back both names:
+
+    1 -> adverbs
+    2 -> adjectives
+    3 -> nouns
+
+This keeps the lookup flexible (a deployment can add a "4", "5", … or repoint
+the symlinks) while the actual word lists keep meaningful names.
+
+Files
+-----
+
+Each file is a plain list of words, one per line, with no comment or blank
+lines: a word is picked by hashing the machine ID to a byte offset into the
+file, so comment/blank lines (although skipped) would bias the selection and
+should be avoided. Each word must be a valid single hostname label (lowercase
+letters, digits, hyphens); invalid entries are skipped. The file is used as-is
+from the highest-priority directory that provides it (/etc wins over /run wins
+over /usr/lib); files are not merged across directories.
+
+Search path (highest priority first):
+
+    /etc/systemd/hostname-wordlist/{1,2,3,...}
+    /run/systemd/hostname-wordlist/...
+    /usr/local/lib/systemd/hostname-wordlist/...
+    /usr/lib/systemd/hostname-wordlist/...
+
+Caveats
+-------
+
+The word for each token is derived deterministically from the machine ID and
+recomputed on every boot; it is not persisted. The position is folded into the
+hash, so repeated "$" tokens stay independent even when they resolve to the same
+list. Changing a word list may change the name a machine gets. If a referenced
+list is missing the name is treated as invalid and the built-in fallback
+hostname is used.
+
+Because a word is chosen by byte offset into the file (rather than loading and
+indexing the whole list), the words are not all equally likely: a word's chance
+tracks the length of the word that precedes it in the list (not its own length),
+so a word listed right after a long word is slightly more likely to be picked.
+The effect is small: about a 12% non-uniformity, i.e. the effective name space
+is ~88% of the nominal product for $-$-$. This is an accepted trade for not
+reading the whole list into memory. If exact uniformity is ever needed, pad
+every word to a fixed width (e.g. with trailing '#') and have the loader strip
+the padding.
+
+Origin
+------
+
+These are the "small" word lists taken from the petname project
+(https://github.com/dustinkirkland/petname), distributed under the Apache
+License 2.0. Distributions are encouraged to ship larger lists (petname also
+provides "medium" and "large") for a bigger name space.
diff --git a/hostname-wordlist/adjectives b/hostname-wordlist/adjectives
new file mode 100644 (file)
index 0000000..bc952f4
--- /dev/null
@@ -0,0 +1,449 @@
+able
+above
+absolute
+accepted
+accurate
+ace
+active
+actual
+adapted
+adapting
+adequate
+adjusted
+advanced
+alert
+alive
+allowed
+allowing
+amazed
+amazing
+ample
+amused
+amusing
+apparent
+apt
+arriving
+artistic
+assured
+assuring
+awaited
+awake
+aware
+balanced
+becoming
+beloved
+better
+big
+blessed
+bold
+boss
+brave
+brief
+bright
+bursting
+busy
+calm
+capable
+capital
+careful
+caring
+casual
+causal
+central
+certain
+champion
+charmed
+charming
+cheerful
+chief
+choice
+civil
+classic
+clean
+clear
+clever
+climbing
+close
+closing
+coherent
+comic
+communal
+complete
+composed
+concise
+concrete
+content
+cool
+correct
+cosmic
+crack
+creative
+credible
+crisp
+crucial
+cuddly
+cunning
+curious
+current
+cute
+daring
+darling
+dashing
+dear
+decent
+deciding
+deep
+definite
+delicate
+desired
+destined
+devoted
+direct
+discrete
+distinct
+diverse
+divine
+dominant
+driven
+driving
+dynamic
+eager
+easy
+electric
+elegant
+emerging
+eminent
+enabled
+enabling
+endless
+engaged
+engaging
+enhanced
+enjoyed
+enormous
+enough
+epic
+equal
+equipped
+eternal
+ethical
+evident
+evolved
+evolving
+exact
+excited
+exciting
+exotic
+expert
+factual
+fair
+faithful
+famous
+fancy
+fast
+feasible
+fine
+finer
+firm
+first
+fit
+fitting
+fleet
+flexible
+flowing
+fluent
+flying
+fond
+frank
+free
+fresh
+full
+fun
+funky
+funny
+game
+generous
+gentle
+genuine
+giving
+glad
+glorious
+glowing
+golden
+good
+gorgeous
+grand
+grateful
+great
+growing
+grown
+guided
+guiding
+handy
+happy
+hardy
+harmless
+healthy
+helped
+helpful
+helping
+heroic
+hip
+holy
+honest
+hopeful
+hot
+huge
+humane
+humble
+humorous
+ideal
+immense
+immortal
+immune
+improved
+in
+included
+infinite
+informed
+innocent
+inspired
+integral
+intense
+intent
+internal
+intimate
+inviting
+joint
+just
+keen
+key
+kind
+knowing
+known
+large
+lasting
+leading
+learning
+legal
+legible
+lenient
+liberal
+light
+liked
+literate
+live
+living
+logical
+loved
+loving
+loyal
+lucky
+magical
+magnetic
+main
+major
+many
+massive
+master
+mature
+maximum
+measured
+meet
+merry
+mighty
+mint
+model
+modern
+modest
+moral
+more
+moved
+moving
+musical
+mutual
+national
+native
+natural
+nearby
+neat
+needed
+neutral
+new
+next
+nice
+noble
+normal
+notable
+noted
+novel
+obliging
+on
+one
+open
+optimal
+optimum
+organic
+oriented
+outgoing
+patient
+peaceful
+perfect
+pet
+picked
+pleasant
+pleased
+pleasing
+poetic
+polished
+polite
+popular
+positive
+possible
+powerful
+precious
+precise
+premium
+prepared
+present
+pretty
+primary
+prime
+pro
+probable
+profound
+promoted
+prompt
+proper
+proud
+proven
+pumped
+pure
+quality
+quick
+quiet
+rapid
+rare
+rational
+ready
+real
+refined
+regular
+related
+relative
+relaxed
+relaxing
+relevant
+relieved
+renewed
+renewing
+resolved
+rested
+rich
+right
+robust
+romantic
+ruling
+sacred
+safe
+saved
+saving
+secure
+select
+selected
+sensible
+set
+settled
+settling
+sharing
+sharp
+shining
+simple
+sincere
+singular
+skilled
+smart
+smashing
+smiling
+smooth
+social
+solid
+sought
+sound
+special
+splendid
+square
+stable
+star
+steady
+sterling
+still
+stirred
+stirring
+striking
+strong
+stunning
+subtle
+suitable
+suited
+summary
+sunny
+super
+superb
+supreme
+sure
+sweeping
+sweet
+talented
+teaching
+tender
+thankful
+thorough
+tidy
+tight
+together
+tolerant
+top
+topical
+tops
+touched
+touching
+tough
+true
+trusted
+trusting
+trusty
+ultimate
+unbiased
+uncommon
+unified
+unique
+united
+up
+upright
+upward
+usable
+useful
+valid
+valued
+vast
+verified
+viable
+vital
+vocal
+wanted
+warm
+wealthy
+welcome
+welcomed
+well
+whole
+willing
+winning
+wired
+wise
+witty
+wondrous
+workable
+working
+worthy
diff --git a/hostname-wordlist/adverbs b/hostname-wordlist/adverbs
new file mode 100644 (file)
index 0000000..25b2306
--- /dev/null
@@ -0,0 +1,261 @@
+abnormally
+absolutely
+accurately
+actively
+actually
+adequately
+admittedly
+adversely
+allegedly
+amazingly
+annually
+apparently
+arguably
+awfully
+badly
+barely
+basically
+blatantly
+blindly
+briefly
+brightly
+broadly
+carefully
+centrally
+certainly
+cheaply
+cleanly
+clearly
+closely
+commonly
+completely
+constantly
+conversely
+correctly
+curiously
+currently
+daily
+deadly
+deeply
+definitely
+directly
+distinctly
+duly
+eagerly
+early
+easily
+eminently
+endlessly
+enormously
+entirely
+equally
+especially
+evenly
+evidently
+exactly
+explicitly
+externally
+extremely
+factually
+fairly
+finally
+firmly
+firstly
+forcibly
+formally
+formerly
+frankly
+freely
+frequently
+friendly
+fully
+generally
+gently
+genuinely
+ghastly
+gladly
+globally
+gradually
+gratefully
+greatly
+grossly
+happily
+hardly
+heartily
+heavily
+hideously
+highly
+honestly
+hopefully
+hopelessly
+horribly
+hugely
+humbly
+ideally
+illegally
+immensely
+implicitly
+incredibly
+indirectly
+infinitely
+informally
+inherently
+initially
+instantly
+intensely
+internally
+jointly
+jolly
+kindly
+largely
+lately
+legally
+lightly
+likely
+literally
+lively
+locally
+logically
+loosely
+loudly
+lovely
+luckily
+mainly
+manually
+marginally
+mentally
+merely
+mildly
+miserably
+mistakenly
+moderately
+monthly
+morally
+mostly
+multiply
+mutually
+namely
+nationally
+naturally
+nearly
+neatly
+needlessly
+newly
+nicely
+nominally
+normally
+notably
+noticeably
+obviously
+oddly
+officially
+only
+openly
+optionally
+overly
+painfully
+partially
+partly
+perfectly
+personally
+physically
+plainly
+pleasantly
+poorly
+positively
+possibly
+precisely
+preferably
+presently
+presumably
+previously
+primarily
+privately
+probably
+promptly
+properly
+publicly
+purely
+quickly
+quietly
+radically
+randomly
+rapidly
+rarely
+rationally
+readily
+really
+reasonably
+recently
+regularly
+reliably
+remarkably
+remotely
+repeatedly
+rightly
+roughly
+routinely
+sadly
+safely
+scarcely
+secondly
+secretly
+seemingly
+sensibly
+separately
+seriously
+severely
+sharply
+shortly
+similarly
+simply
+sincerely
+singularly
+slightly
+slowly
+smoothly
+socially
+solely
+specially
+steadily
+strangely
+strictly
+strongly
+subtly
+suddenly
+suitably
+supposedly
+surely
+terminally
+terribly
+thankfully
+thoroughly
+tightly
+totally
+trivially
+truly
+typically
+ultimately
+unduly
+uniformly
+uniquely
+unlikely
+urgently
+usefully
+usually
+utterly
+vaguely
+vastly
+verbally
+vertically
+vigorously
+violently
+virtually
+visually
+weekly
+wholly
+widely
+wildly
+willingly
+wrongly
+yearly
diff --git a/hostname-wordlist/meson.build b/hostname-wordlist/meson.build
new file mode 100644 (file)
index 0000000..65abcb9
--- /dev/null
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+if get_option('hostname-wordlist')
+        install_data(
+                'adverbs',
+                'adjectives',
+                'nouns',
+                install_dir : libexecdir / 'hostname-wordlist')
+
+        # The '$' hostname tokens look up word lists by position ("1", "2", "3", …); ship those names as
+        # symlinks to the semantic lists so the same files back both.
+        foreach link : [['1', 'adverbs'], ['2', 'adjectives'], ['3', 'nouns']]
+                install_symlink(
+                        link[0],
+                        install_dir : libexecdir / 'hostname-wordlist',
+                        pointing_to : link[1])
+        endforeach
+endif
diff --git a/hostname-wordlist/nouns b/hostname-wordlist/nouns
new file mode 100644 (file)
index 0000000..dbe898f
--- /dev/null
@@ -0,0 +1,449 @@
+ox
+ant
+ape
+asp
+bat
+bee
+boa
+bug
+cat
+cod
+cow
+cub
+doe
+dog
+eel
+eft
+elf
+elk
+emu
+ewe
+fly
+fox
+gar
+gnu
+hen
+hog
+imp
+jay
+kid
+kit
+koi
+lab
+man
+owl
+pig
+pug
+pup
+ram
+rat
+ray
+yak
+bass
+bear
+bird
+boar
+buck
+bull
+calf
+chow
+clam
+colt
+crab
+crow
+dane
+deer
+dodo
+dory
+dove
+drum
+duck
+fawn
+fish
+flea
+foal
+fowl
+frog
+gnat
+goat
+grub
+gull
+hare
+hawk
+ibex
+joey
+kite
+kiwi
+lamb
+lark
+lion
+loon
+lynx
+mako
+mink
+mite
+mole
+moth
+mule
+mutt
+newt
+orca
+oryx
+pika
+pony
+puma
+seal
+shad
+slug
+sole
+stag
+stud
+swan
+tahr
+teal
+tick
+toad
+tuna
+wasp
+wolf
+worm
+wren
+yeti
+adder
+akita
+alien
+aphid
+bison
+boxer
+bream
+bunny
+burro
+camel
+chimp
+civet
+cobra
+coral
+corgi
+crane
+dingo
+drake
+eagle
+egret
+filly
+finch
+gator
+gecko
+ghost
+ghoul
+goose
+guppy
+heron
+hippo
+horse
+hound
+husky
+hyena
+koala
+krill
+leech
+lemur
+liger
+llama
+louse
+macaw
+midge
+molly
+moose
+moray
+mouse
+panda
+perch
+prawn
+quail
+racer
+raven
+rhino
+robin
+satyr
+shark
+sheep
+shrew
+skink
+skunk
+sloth
+snail
+snake
+snipe
+squid
+stork
+swift
+tapir
+tetra
+tiger
+troll
+trout
+viper
+wahoo
+whale
+zebra
+alpaca
+amoeba
+baboon
+badger
+beagle
+bedbug
+beetle
+bengal
+bobcat
+caiman
+cattle
+cicada
+collie
+condor
+cougar
+coyote
+dassie
+dragon
+earwig
+falcon
+feline
+ferret
+gannet
+gibbon
+glider
+goblin
+gopher
+grouse
+guinea
+hermit
+hornet
+iguana
+impala
+insect
+jackal
+jaguar
+jennet
+kitten
+kodiak
+lizard
+locust
+maggot
+magpie
+mammal
+mantis
+marlin
+marmot
+marten
+martin
+mayfly
+minnow
+monkey
+mullet
+muskox
+ocelot
+oriole
+osprey
+oyster
+parrot
+pigeon
+piglet
+poodle
+possum
+python
+quagga
+rabbit
+raptor
+rodent
+roughy
+salmon
+sawfly
+serval
+shiner
+shrimp
+spider
+sponge
+tarpon
+thrush
+tomcat
+toucan
+turkey
+turtle
+urchin
+vervet
+walrus
+weasel
+weevil
+wombat
+anchovy
+anemone
+bluejay
+buffalo
+bulldog
+buzzard
+caribou
+catfish
+chamois
+cheetah
+chicken
+chigger
+cowbird
+crappie
+crawdad
+cricket
+dogfish
+dolphin
+firefly
+garfish
+gazelle
+gelding
+giraffe
+gobbler
+gorilla
+goshawk
+grackle
+griffon
+grizzly
+grouper
+haddock
+hagfish
+halibut
+hamster
+herring
+javelin
+jawfish
+jaybird
+katydid
+ladybug
+lamprey
+lemming
+leopard
+lioness
+lobster
+macaque
+mallard
+mammoth
+manatee
+mastiff
+meerkat
+mollusk
+monarch
+mongrel
+monitor
+monster
+mudfish
+muskrat
+mustang
+narwhal
+oarfish
+octopus
+opossum
+ostrich
+panther
+pegasus
+pelican
+penguin
+phoenix
+piranha
+polecat
+primate
+quetzal
+raccoon
+rattler
+redbird
+redfish
+reptile
+rooster
+sawfish
+sculpin
+seagull
+skylark
+snapper
+spaniel
+sparrow
+sunbeam
+sunbird
+sunfish
+tadpole
+terrier
+unicorn
+vulture
+wallaby
+walleye
+warthog
+whippet
+wildcat
+aardvark
+airedale
+albacore
+anteater
+antelope
+arachnid
+barnacle
+basilisk
+blowfish
+bluebird
+bluegill
+bonefish
+bullfrog
+cardinal
+chipmunk
+crayfish
+dinosaur
+doberman
+duckling
+elephant
+escargot
+flamingo
+flounder
+foxhound
+glowworm
+goldfish
+grubworm
+hedgehog
+honeybee
+hookworm
+humpback
+kangaroo
+killdeer
+kingfish
+labrador
+lacewing
+ladybird
+lionfish
+longhorn
+mackerel
+malamute
+marmoset
+mastodon
+moccasin
+mongoose
+monkfish
+mosquito
+pangolin
+parakeet
+pheasant
+pipefish
+platypus
+polliwog
+porpoise
+reindeer
+ringtail
+sailfish
+scorpion
+seahorse
+seasnail
+sheepdog
+shepherd
+silkworm
+squirrel
+stallion
+starfish
+starling
+stingray
+stinkbug
+sturgeon
+terrapin
+titmouse
+tortoise
+treefrog
+werewolf
index 20f00057fdc50b1dd49a6f62832d8f546dbe9dec..04d6769411ab72c2ea85872d0a67b561a10e656c 100644 (file)
@@ -6,7 +6,7 @@
 ]>
 <!-- SPDX-License-Identifier: LGPL-2.1-or-later -->
 
-<refentry id="hostname">
+<refentry id="hostname" xmlns:xi="http://www.w3.org/2001/XInclude">
   <refentryinfo>
     <title>hostname</title>
     <productname>systemd</productname>
     <literal>foobar-????-????</literal> will automatically expand to <literal>foobar-92a9-061c</literal> or
     similar, depending on the local machine ID.</para>
 
+    <para id="word-hostname-pattern">In addition, the token <literal>$</literal> is substituted by a word picked
+    deterministically from a word list, again derived from the
+    <citerefentry><refentrytitle>machine-id</refentrytitle><manvolnum>5</manvolnum></citerefentry> by
+    cryptographic hashing. Each <literal>$</literal> is positional: the first <literal>$</literal> uses the word
+    list file named <filename>1</filename>, the second <filename>2</filename>, and so on. This allows
+    human-friendly names, for example <literal>$-$-$-????</literal> might expand to
+    <literal>wildly-happy-octopus-92a9</literal>. The word lists are searched for in
+    <filename>/etc/systemd/hostname-wordlist/</filename>, <filename>/run/systemd/hostname-wordlist/</filename>,
+    <filename>/usr/local/lib/systemd/hostname-wordlist/</filename> and
+    <filename>/usr/lib/systemd/hostname-wordlist/</filename> (the first directory providing a given list wins,
+    lists are not merged); one word per line, with empty lines and lines starting with <literal>#</literal>
+    ignored.</para>
+
+    <para>The word for each token is derived deterministically from the machine ID and recomputed on every
+    boot (the lists are not loaded wholesale: a word is chosen by hashing to a byte offset into the file).
+    Consequently the word lists must be kept stable: changing a list (adding, removing, or reordering words)
+    may change the name a machine already has, so installations that rely on persistent hostnames must not
+    modify the lists after deployment. If a referenced list is missing the
+    name is treated as invalid and the built-in fallback hostname is used. The combined name space is the
+    product of the list sizes, so collisions follow the birthday bound; append a few <literal>?</literal>
+    characters for extra entropy when uniqueness across a large fleet matters.</para>
+
+    <para>Note that hostname can be at most 64 characters long. The word lists and the pattern should be
+    chosen so that the longest possible expansion (the longest words from each list plus any literal and
+    <literal>?</literal> characters and separators) stays within this limit. An expanded name that exceeds
+    the limit is considered invalid and the built-in fallback hostname is used.</para>
+
+    <xi:include href="version-info.xml" xpointer="v262"/>
+
     <para>You may use
     <citerefentry><refentrytitle>hostnamectl</refentrytitle><manvolnum>1</manvolnum></citerefentry> to change
     the value of this file during runtime from the command line. Use
index ff11c0c10caf38d8b9633b61a8e56f55e8b0e503..f03032aa2ecc25cec277d7629876cecfc0cf4e7a 100644 (file)
@@ -2454,6 +2454,7 @@ subdir('test')
 #####################################################################
 
 subdir('docs/var-log')
+subdir('hostname-wordlist')
 subdir('hwdb.d')
 subdir('man')
 subdir('modprobe.d')
index b5856dd8f87bab6883a0358cdbd6acc62c7424b8..b2fddf8a34a5d0736c743db49c05ad96e62cf7ad 100644 (file)
@@ -248,6 +248,8 @@ option('configfiledir', type : 'string', value : '',
 
 option('fallback-hostname', type : 'string', value : 'localhost',
        description : 'the hostname used if none configured')
+option('hostname-wordlist', type : 'boolean', value : false,
+       description : 'install default word lists for $ hostname wildcards')
 option('extra-net-naming-schemes', type : 'string',
        description : 'comma-separated list of extra net.naming_scheme= definitions')
 option('default-net-naming-scheme', type : 'string', value : 'latest',
index e12ec01af21c34b239320e07945d780f8bf639f1..ff23f0786331aeabd0fc51d1378c4c4d1c77b351 100644 (file)
@@ -18,7 +18,7 @@ char* get_default_hostname_raw(void) {
 
         const char *e = secure_getenv("SYSTEMD_DEFAULT_HOSTNAME");
         if (e) {
-                if (hostname_is_valid(e, VALID_HOSTNAME_QUESTION_MARK))
+                if (hostname_is_valid(e, VALID_HOSTNAME_QUESTION_MARK|VALID_HOSTNAME_WORD_TOKEN))
                         return strdup(e);
 
                 log_debug("Invalid hostname in $SYSTEMD_DEFAULT_HOSTNAME, ignoring: %s", e);
@@ -29,7 +29,7 @@ char* get_default_hostname_raw(void) {
         if (r < 0)
                 log_debug_errno(r, "Failed to parse os-release, ignoring: %m");
         else if (f) {
-                if (hostname_is_valid(f, VALID_HOSTNAME_QUESTION_MARK))
+                if (hostname_is_valid(f, VALID_HOSTNAME_QUESTION_MARK|VALID_HOSTNAME_WORD_TOKEN))
                         return TAKE_PTR(f);
 
                 log_debug("Invalid hostname in os-release, ignoring: %s", f);
@@ -82,7 +82,9 @@ bool hostname_is_valid(const char *s, ValidHostnameFlags flags) {
                         hyphen = true;
 
                 } else {
-                        if (!valid_ldh_char(*p) && (*p != '?' || !FLAGS_SET(flags, VALID_HOSTNAME_QUESTION_MARK)))
+                        if (!valid_ldh_char(*p) &&
+                            (*p != '?' || !FLAGS_SET(flags, VALID_HOSTNAME_QUESTION_MARK)) &&
+                            (*p != '$' || !FLAGS_SET(flags, VALID_HOSTNAME_WORD_TOKEN)))
                                 return false;
 
                         dot = false;
@@ -124,7 +126,7 @@ char* hostname_cleanup(char *s) {
                         dot = false;
                         hyphen = true;
 
-                } else if (valid_ldh_char(*p) || *p == '?') {
+                } else if (valid_ldh_char(*p) || IN_SET(*p, '?', '$')) {
                         *(d++) = *p;
                         dot = false;
                         hyphen = false;
index f3d904a1bbf718e141561d9c82b571c7fe638f2b..0fdc5b483b71fe9d718f9ac79b2d53501eef9ef5 100644 (file)
@@ -15,6 +15,7 @@ typedef enum ValidHostnameFlags {
         VALID_HOSTNAME_TRAILING_DOT  = 1 << 0,   /* Accept trailing dot on multi-label names */
         VALID_HOSTNAME_DOT_HOST      = 1 << 1,   /* Accept ".host" as valid hostname */
         VALID_HOSTNAME_QUESTION_MARK = 1 << 2,   /* Accept "?" as place holder for hashed machine ID value */
+        VALID_HOSTNAME_WORD_TOKEN    = 1 << 3,   /* Accept "$" as place holder for a word list substitution */
 } ValidHostnameFlags;
 
 bool hostname_is_valid(const char *s, ValidHostnameFlags flags) _pure_;
index 098ce80500b31ebb404dcc4c7f59819c80376bee..31afe1ebfcd6b65da212900f32c0bf1fcc517681 100644 (file)
@@ -147,13 +147,13 @@ static void context_read_etc_hostname(Context *c) {
                 if (r != -ENOENT)
                         log_warning_errno(r, "Failed to read /etc/hostname, ignoring: %m");
         } else {
-                _cleanup_free_ char *substituted = strdup(c->data[PROP_STATIC_HOSTNAME]);
-                if (!substituted)
-                        return (void) log_oom();
+                _cleanup_free_ char *substituted = NULL;
 
-                r = hostname_substitute_wildcards(substituted);
+                r = hostname_substitute_wildcards(c->data[PROP_STATIC_HOSTNAME], &substituted);
                 if (r < 0)
                         log_warning_errno(r, "Failed to substitute wildcards in /etc/hostname, ignoring: %m");
+                else if (!hostname_is_valid(substituted, VALID_HOSTNAME_TRAILING_DOT))
+                        log_warning("Hostname '%s' in /etc/hostname is invalid after expansion, ignoring.", substituted);
                 else
                         c->data[PROP_STATIC_HOSTNAME_SUBSTITUTED_WILDCARDS] = TAKE_PTR(substituted);
         }
@@ -1376,11 +1376,9 @@ static int validate_and_substitute_hostname(const char *name, char **ret_substit
                 return 0;
         }
 
-        _cleanup_free_ char *substituted = strdup(name);
-        if (!substituted)
-                return log_oom();
+        _cleanup_free_ char *substituted = NULL;
 
-        r = hostname_substitute_wildcards(substituted);
+        r = hostname_substitute_wildcards(name, &substituted);
         if (r < 0)
                 return log_error_errno(r, "Failed to substitute wildcards in hostname: %m");
 
index ee88e2a877b777412e7d525a3e902c431d5984b2..4a1850ae2897231c95a652da168f9d94251a7013 100644 (file)
@@ -1,13 +1,16 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 
+#include <fcntl.h>
 #include <sched.h>
 #include <stdio.h>
+#include <sys/stat.h>
 #include <sys/utsname.h>
 #include <unistd.h>
 
 #include "sd-daemon.h"
 
 #include "alloc-util.h"
+#include "constants.h"
 #include "creds-util.h"
 #include "fd-util.h"
 #include "fileio.h"
@@ -23,6 +26,8 @@
 #include "proc-cmdline.h"
 #include "process-util.h"
 #include "siphash24.h"
+#include "stat-util.h"
+#include "stdio-util.h"
 #include "string-table.h"
 #include "string-util.h"
 
@@ -119,9 +124,13 @@ int read_etc_hostname_stream(FILE *f, bool substitute_wildcards, char **ret) {
                         continue;
 
                 if (substitute_wildcards) {
-                        r = hostname_substitute_wildcards(line);
+                        _cleanup_free_ char *substituted = NULL;
+
+                        r = hostname_substitute_wildcards(line, &substituted);
                         if (r < 0)
                                 return r;
+
+                        free_and_replace(line, substituted);
                 }
 
                 hostname_cleanup(line); /* normalize the hostname */
@@ -130,7 +139,7 @@ int read_etc_hostname_stream(FILE *f, bool substitute_wildcards, char **ret) {
                 if (!hostname_is_valid(
                                     line,
                                     VALID_HOSTNAME_TRAILING_DOT|
-                                    (substitute_wildcards ? 0 : VALID_HOSTNAME_QUESTION_MARK)))
+                                    (substitute_wildcards ? 0 : VALID_HOSTNAME_QUESTION_MARK|VALID_HOSTNAME_WORD_TOKEN)))
                         return -EBADMSG;
 
                 *ret = TAKE_PTR(line);
@@ -254,48 +263,178 @@ static const char* const hostname_source_table[] = {
 
 DEFINE_STRING_TABLE_LOOKUP(hostname_source, HostnameSource);
 
-int hostname_substitute_wildcards(char *name) {
+static int hostname_open_wordlist(const char *file, FILE **ret) {
+        _cleanup_fclose_ FILE *f = NULL;
+        int r;
+
+        assert(file);
+        assert(ret);
+
+        /* Opens one of the numbered hostname word list files ("1", "2", "3", ...) for the '$' wildcards. */
+        const char *override = secure_getenv("SYSTEMD_HOSTNAME_WORDLIST_PATH");
+        r = search_and_fopen(
+                        file,
+                        "re",
+                        /* root= */ NULL,
+                        override ? (const char**) STRV_MAKE(override) : (const char**) CONF_PATHS_STRV("systemd/hostname-wordlist"),
+                        &f,
+                        /* ret_path= */ NULL);
+        if (r < 0)
+                return r;
+
+        *ret = TAKE_PTR(f);
+        return 0;
+}
+
+static int hostname_pick_word(sd_id128_t mid, size_t pos, char **ret) {
+        static const sd_id128_t word_key = SD_ID128_MAKE(2d,9f,1c,7a,4b,8e,43,11,9a,6d,5f,02,c8,77,e3,14);
+        _cleanup_fclose_ FILE *f = NULL;
+        struct stat st;
+        bool wrapped = false;
+        uint64_t h;
+        int r;
+
+        assert(pos >= 1);
+        assert(ret);
+
+        /* The n-th '$' in a template reads the word list file named after its position, i.e. "1", "2", ... */
+        char file[DECIMAL_STR_MAX(size_t)];
+        xsprintf(file, "%zu", pos);
+
+        r = hostname_open_wordlist(file, &f);
+        if (r < 0)
+                return r;
+
+        if (fstat(fileno(f), &st) < 0)
+                return -errno;
+        r = stat_verify_regular(&st);
+        if (r < 0)
+                return r;
+        if (st.st_size == 0)
+                return -ENOENT;
+
+        /* Pick a word without reading the whole list into memory: hash the machine ID and word position to a
+         * byte offset. This stream is independent of the '?' nibble stream, so pure-'?' templates keep
+         * producing byte-identical output. Stable as long as the wordlist is stable. */
+        struct siphash state;
+        siphash24_init(&state, word_key.bytes);
+        siphash24_compress_typesafe(mid, &state);
+        siphash24_compress_typesafe(pos, &state);
+        h = siphash24_finalize(&state);
+
+        if (fseeko(f, (off_t) (h % (uint64_t) st.st_size), SEEK_SET) < 0)
+                return -errno;
+
+        /* We mostly landed mid-line, so read/discard the current line here. If the file was shrunk by a
+         * concurrent modification we might have seeked at/past EOF, so wrap around to the beginning. */
+        r = read_line(f, LONG_LINE_MAX, NULL);
+        if (r < 0)
+                return r;
+        if (r == 0) {
+                wrapped = true;
+                rewind(f);
+        }
+
+        for (;;) {
+                _cleanup_free_ char *line = NULL;
+
+                r = read_stripped_line(f, LONG_LINE_MAX, &line);
+                if (r < 0)
+                        return r;
+                if (r == 0) { /* hit EOF: we started at a random offset, wrap around to the beginning */
+                        if (wrapped) /* already wrapped once, the file contains no usable word at all */
+                                return -ENOENT;
+                        wrapped = true;
+                        rewind(f);
+                        continue;
+                }
+
+                /* Skip empty lines and comments */
+                if (IN_SET(line[0], '\0', '#'))
+                        continue;
+
+                /* Each word must be a valid single hostname label on its own; lowercase it and silently skip
+                 * bogus entries. */
+                ascii_strlower(line);
+                if (!hostname_is_valid(line, /* flags= */ 0))
+                        continue;
+
+                *ret = TAKE_PTR(line);
+                return 0;
+        }
+}
+
+int hostname_substitute_wildcards(const char *name, char **ret) {
         static const sd_id128_t key = SD_ID128_MAKE(98,10,ad,df,8d,7d,4f,b5,89,1b,4b,56,ac,c2,26,8f);
         sd_id128_t mid = SD_ID128_NULL;
+        _cleanup_free_ char *result = NULL;
         size_t left_bits = 0, counter = 0;
+        size_t word_pos = 0;
         uint64_t h = 0;
         int r;
 
         assert(name);
+        assert(ret);
 
-        /* Replaces every occurrence of '?' in the specified string with a nibble hashed from
-         * /etc/machine-id. This is supposed to be used on /etc/hostname files that want to automatically
-         * configure a hostname derived from the machine ID in some form.
+        if (isempty(name))
+                return strdup_to(ret, "");
+
+        /* Expands wildcards in the specified string, deriving the inserted values deterministically from
+         * /etc/machine-id:
          *
-         * Note that this does not directly use the machine ID, because that's not necessarily supposed to be
-         * public information to be broadcast on the network, while the hostname certainly is. */
-
-        for (char *n = name; ; n++) {
-                n = strchr(n, '?');
-                if (!n)
-                        return 0;
-
-                if (left_bits <= 0) {
-                        if (sd_id128_is_null(mid)) {
-                                r = sd_id128_get_machine(&mid);
-                                if (r < 0)
-                                        return r;
-                        }
+         *   '?'  is replaced by a single hex nibble hashed from the machine ID.
+         *   '$'  is replaced by a word picked from a word list; the n-th '$' in the string uses the list
+         *        file named "n"
+         *
+         * This is supposed to be used on /etc/hostname files that want to automatically configure a hostname
+         * derived from the machine ID in some form, e.g. "$-$-????".
+         *
+         * Note that this does not directly expose the machine ID, because that's not necessarily supposed to
+         * be public information to be broadcast on the network, while the hostname certainly is. */
 
-                        struct siphash state;
-                        siphash24_init(&state, key.bytes);
-                        siphash24_compress_typesafe(mid, &state);
-                        siphash24_compress_typesafe(counter, &state); /* counter mode */
-                        h = siphash24_finalize(&state);
-                        left_bits = sizeof(h) * 8;
-                        counter++;
+        for (const char *n = name; *n; n++) {
+                if (IN_SET(*n, '?', '$') && sd_id128_is_null(mid)) {
+                        r = sd_id128_get_machine(&mid);
+                        if (r < 0)
+                                return r;
                 }
 
-                assert(left_bits >= 4);
-                *n = hexchar(h & 0xf);
-                h >>= 4;
-                left_bits -= 4;
+                if (*n == '?') {
+                        if (left_bits <= 0) {
+                                struct siphash state;
+                                siphash24_init(&state, key.bytes);
+                                siphash24_compress_typesafe(mid, &state);
+                                siphash24_compress_typesafe(counter, &state); /* counter mode */
+                                h = siphash24_finalize(&state);
+                                left_bits = sizeof(h) * 8;
+                                counter++;
+                        }
+
+                        assert(left_bits >= 4);
+                        char c = hexchar(h & 0xf);
+                        h >>= 4;
+                        left_bits -= 4;
+
+                        if (!strextendn(&result, &c, 1))
+                                return -ENOMEM;
+
+                } else if (*n == '$') {
+                        /* Each '$' is an independent word token; the n-th one picks from word list "n".
+                         * There is no escape for a literal '$', as it is not a valid hostname character. */
+                        _cleanup_free_ char *w = NULL;
+                        r = hostname_pick_word(mid, ++word_pos, &w);
+                        if (r < 0)
+                                return r;
+
+                        if (!strextend(&result, w))
+                                return -ENOMEM;
+
+                } else if (!strextendn(&result, n, 1))
+                        return -ENOMEM;
         }
+
+        *ret = TAKE_PTR(result);
+        return 0;
 }
 
 char* get_default_hostname(void) {
@@ -305,13 +444,20 @@ char* get_default_hostname(void) {
         if (!h)
                 return NULL;
 
-        r = hostname_substitute_wildcards(h);
+        _cleanup_free_ char *substituted = NULL;
+        r = hostname_substitute_wildcards(h, &substituted);
         if (r < 0) {
                 log_debug_errno(r, "Failed to substitute wildcards in hostname, falling back to built-in name: %m");
                 return strdup(FALLBACK_HOSTNAME);
         }
 
-        return TAKE_PTR(h);
+        /* Each token expands to a whole word, so the concrete name may exceed the length limit. */
+        if (!hostname_is_valid(substituted, VALID_HOSTNAME_TRAILING_DOT)) {
+                log_debug("Substituted hostname '%s' is invalid, falling back to built-in name.", substituted);
+                return strdup(FALLBACK_HOSTNAME);
+        }
+
+        return TAKE_PTR(substituted);
 }
 
 int gethostname_full(GetHostnameFlags flags, char **ret) {
index ee1c932d284800d583760700cc106434ff8b2865..802f7c9f2025655456d620c75c3273ad4215fe46 100644 (file)
@@ -22,7 +22,7 @@ int read_etc_hostname(const char *path, bool substitute_wildcards, char **ret);
 void hostname_update_source_hint(const char *hostname, HostnameSource source);
 int hostname_setup(bool really);
 
-int hostname_substitute_wildcards(char *name);
+int hostname_substitute_wildcards(const char *name, char **ret);
 
 char* get_default_hostname(void);
 
index e3fc8a220b6a40d51cbfd12080f85830226cf411..0ac0acd475155fe5e9e258bd7b3ec2d9b2a19162 100644 (file)
 #include "hostname-setup.h"
 #include "hostname-util.h"
 #include "id128-util.h"
+#include "path-util.h"
 #include "pidref.h"
 #include "process-util.h"
+#include "rm-rf.h"
 #include "tests.h"
 #include "tmpfile-util.h"
 
@@ -86,24 +88,54 @@ TEST(hostname_substitute_wildcards) {
                 return (void) log_tests_skipped_errno(r, "skipping wildcard hostname tests, no machine ID defined");
 
         _cleanup_free_ char *buf = NULL;
-        ASSERT_NOT_NULL((buf = strdup("")));
-        ASSERT_OK(hostname_substitute_wildcards(buf));
+        ASSERT_OK(hostname_substitute_wildcards("", &buf));
         ASSERT_STREQ(buf, "");
         ASSERT_NULL(buf = mfree(buf));
 
-        ASSERT_NOT_NULL((buf = strdup("hogehoge")));
-        ASSERT_OK(hostname_substitute_wildcards(buf));
+        ASSERT_OK(hostname_substitute_wildcards("hogehoge", &buf));
         ASSERT_STREQ(buf, "hogehoge");
         ASSERT_NULL(buf = mfree(buf));
 
-        ASSERT_NOT_NULL((buf = strdup("hoge??hoge??foo?")));
-        ASSERT_OK(hostname_substitute_wildcards(buf));
+        ASSERT_OK(hostname_substitute_wildcards("hoge??hoge??foo?", &buf));
         log_debug("hostname_substitute_wildcards(\"hoge??hoge??foo?\"): → \"%s\"", buf);
         ASSERT_EQ(fnmatch("hoge??hoge??foo?", buf, /* flags= */ 0), 0);
         ASSERT_TRUE(hostname_is_valid(buf, /* flags= */ 0));
         ASSERT_NULL(buf = mfree(buf));
 }
 
+TEST(hostname_substitute_wildcards_words) {
+        _cleanup_(rm_rf_physical_and_freep) char *d = NULL;
+        int r;
+
+        r = sd_id128_get_machine(NULL);
+        if (ERRNO_IS_NEG_MACHINE_ID_UNSET(r))
+                return (void) log_tests_skipped_errno(r, "skipping word hostname tests, no machine ID defined");
+
+        ASSERT_OK(mkdtemp_malloc("/tmp/hostname-wordlist.XXXXXX", &d));
+
+        /* The n-th '$' reads the word list file named after its position. */
+        _cleanup_free_ char *one_list = ASSERT_PTR(path_join(d, "1"));
+        _cleanup_free_ char *two_list = ASSERT_PTR(path_join(d, "2"));
+        ASSERT_OK(write_string_file(one_list, "happy\nsad\n# comment\n\njolly\n", WRITE_STRING_FILE_CREATE));
+        ASSERT_OK(write_string_file(two_list, "octopus\nfalcon\nINVALID_WORD!\nbadger\n", WRITE_STRING_FILE_CREATE));
+        ASSERT_OK(setenv("SYSTEMD_HOSTNAME_WORDLIST_PATH", d, /* overwrite= */ true));
+
+        _cleanup_free_ char *a = NULL, *b = NULL;
+        ASSERT_OK(hostname_substitute_wildcards("$-$", &a));
+        log_debug("hostname_substitute_wildcards(\"$-$\"): → \"%s\"", a);
+        ASSERT_TRUE(hostname_is_valid(a, /* flags= */ 0));
+
+        /* Fully deterministic: same machine ID + same lists → same name */
+        ASSERT_OK(hostname_substitute_wildcards("$-$", &b));
+        ASSERT_STREQ(a, b);
+
+        /* Missing list (no file "3") → error (caller falls back to built-in hostname) */
+        _cleanup_free_ char *e = NULL;
+        ASSERT_ERROR(hostname_substitute_wildcards("$-$-$", &e), ENOENT);
+
+        ASSERT_OK(unsetenv("SYSTEMD_HOSTNAME_WORDLIST_PATH"));
+}
+
 TEST(hostname_setup) {
         hostname_setup(false);
 }
index 7ff9ca85311fffb9be9b8b7ff9725b13c83aa68c..c1238f83a81d32c3e5f69699c213a370b513ca2b 100755 (executable)
@@ -283,6 +283,43 @@ testcase_wildcard() {
     assert_in "Static hostname: foo-" "$(hostnamectl)"
 }
 
+restore_wildcard_words() {
+    rm -rf /etc/systemd/hostname-wordlist
+    if [[ -d /tmp/hostname-wordlist.bak ]]; then
+        mv /tmp/hostname-wordlist.bak /etc/systemd/hostname-wordlist
+    fi
+    hostnamectl set-hostname "$SAVED"
+}
+
+testcase_wildcard_words() {
+    # The n-th '$' token is substituted deterministically from the machine ID using the
+    # word list file named after its position (see hostname(5) and hostname-wordlist/README).
+    SAVED=""
+    [[ -f /etc/hostname ]] && SAVED="$(cat /etc/hostname)"
+    [[ -d /etc/systemd/hostname-wordlist ]] && mv /etc/systemd/hostname-wordlist /tmp/hostname-wordlist.bak
+    trap restore_wildcard_words EXIT
+
+    mkdir -p /etc/systemd/hostname-wordlist
+    printf 'wildly\nquietly\n' >/etc/systemd/hostname-wordlist/1
+    printf 'happy\nsad\n'      >/etc/systemd/hostname-wordlist/2
+    printf 'octopus\nfalcon\n' >/etc/systemd/hostname-wordlist/3
+
+    # each '$' expands to a word from the list at its position
+    hostnamectl set-hostname '$-$-$'
+    H="$(hostname)"
+    assert_neq "$H" '$-$-$'
+    assert_eq "$(cat /etc/hostname)" '$-$-$'
+    IFS='-' read -r w1 w2 w3 <<<"$H"
+    grep -Fx -- "$w1" /etc/systemd/hostname-wordlist/1 >/dev/null
+    grep -Fx -- "$w2" /etc/systemd/hostname-wordlist/2 >/dev/null
+    grep -Fx -- "$w3" /etc/systemd/hostname-wordlist/3 >/dev/null
+
+    # the choice is deterministic: setting the same template again yields the same name
+    hostnamectl set-hostname testhost
+    hostnamectl set-hostname '$-$-$'
+    assert_eq "$(hostname)" "$H"
+}
+
 teardown_hostnamed_alternate_paths() {
     set +eu