From: Michael Vogt Date: Wed, 3 Jun 2026 15:05:55 +0000 (+0200) Subject: hostname: add $ hostname substitution and petnames X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=df12d58f8be10c0acf514e584c916dda56c66e2e;p=thirdparty%2Fsystemd.git hostname: add $ hostname substitution and petnames 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 --- diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index a55e91ff203..b528fce37da 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -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 index 00000000000..1cda8ab7cc2 --- /dev/null +++ b/hostname-wordlist/README @@ -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 index 00000000000..bc952f4187c --- /dev/null +++ b/hostname-wordlist/adjectives @@ -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 index 00000000000..25b23068782 --- /dev/null +++ b/hostname-wordlist/adverbs @@ -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 index 00000000000..65abcb96d75 --- /dev/null +++ b/hostname-wordlist/meson.build @@ -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 index 00000000000..dbe898fc3a5 --- /dev/null +++ b/hostname-wordlist/nouns @@ -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 diff --git a/man/hostname.xml b/man/hostname.xml index 20f00057fdc..04d6769411a 100644 --- a/man/hostname.xml +++ b/man/hostname.xml @@ -6,7 +6,7 @@ ]> - + hostname systemd @@ -52,6 +52,35 @@ foobar-????-???? will automatically expand to foobar-92a9-061c or similar, depending on the local machine ID. + In addition, the token $ is substituted by a word picked + deterministically from a word list, again derived from the + machine-id5 by + cryptographic hashing. Each $ is positional: the first $ uses the word + list file named 1, the second 2, and so on. This allows + human-friendly names, for example $-$-$-???? might expand to + wildly-happy-octopus-92a9. The word lists are searched for in + /etc/systemd/hostname-wordlist/, /run/systemd/hostname-wordlist/, + /usr/local/lib/systemd/hostname-wordlist/ and + /usr/lib/systemd/hostname-wordlist/ (the first directory providing a given list wins, + lists are not merged); one word per line, with empty lines and lines starting with # + ignored. + + 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 ? + characters for extra entropy when uniqueness across a large fleet matters. + + 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 + ? 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. + + + You may use hostnamectl1 to change the value of this file during runtime from the command line. Use diff --git a/meson.build b/meson.build index ff11c0c10ca..f03032aa2ec 100644 --- a/meson.build +++ b/meson.build @@ -2454,6 +2454,7 @@ subdir('test') ##################################################################### subdir('docs/var-log') +subdir('hostname-wordlist') subdir('hwdb.d') subdir('man') subdir('modprobe.d') diff --git a/meson_options.txt b/meson_options.txt index b5856dd8f87..b2fddf8a34a 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -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', diff --git a/src/basic/hostname-util.c b/src/basic/hostname-util.c index e12ec01af21..ff23f078633 100644 --- a/src/basic/hostname-util.c +++ b/src/basic/hostname-util.c @@ -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; diff --git a/src/basic/hostname-util.h b/src/basic/hostname-util.h index f3d904a1bbf..0fdc5b483b7 100644 --- a/src/basic/hostname-util.h +++ b/src/basic/hostname-util.h @@ -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_; diff --git a/src/hostname/hostnamed.c b/src/hostname/hostnamed.c index 098ce80500b..31afe1ebfcd 100644 --- a/src/hostname/hostnamed.c +++ b/src/hostname/hostnamed.c @@ -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"); diff --git a/src/shared/hostname-setup.c b/src/shared/hostname-setup.c index ee88e2a877b..4a1850ae289 100644 --- a/src/shared/hostname-setup.c +++ b/src/shared/hostname-setup.c @@ -1,13 +1,16 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include #include #include +#include #include #include #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) { diff --git a/src/shared/hostname-setup.h b/src/shared/hostname-setup.h index ee1c932d284..802f7c9f202 100644 --- a/src/shared/hostname-setup.h +++ b/src/shared/hostname-setup.h @@ -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); diff --git a/src/test/test-hostname-setup.c b/src/test/test-hostname-setup.c index e3fc8a220b6..0ac0acd4751 100644 --- a/src/test/test-hostname-setup.c +++ b/src/test/test-hostname-setup.c @@ -10,8 +10,10 @@ #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); } diff --git a/test/units/TEST-71-HOSTNAME.sh b/test/units/TEST-71-HOSTNAME.sh index 7ff9ca85311..c1238f83a81 100755 --- a/test/units/TEST-71-HOSTNAME.sh +++ b/test/units/TEST-71-HOSTNAME.sh @@ -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