diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index a55e91ff20..b528fce37d 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 0000000000..1cda8ab7cc --- /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 0000000000..bc952f4187 --- /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 0000000000..25b2306878 --- /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 0000000000..65abcb96d7 --- /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 0000000000..dbe898fc3a --- /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 20f00057fd..04d6769411 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 ff11c0c10c..f03032aa2e 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 b5856dd8f8..b2fddf8a34 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 e12ec01af2..ff23f07863 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 f3d904a1bb..0fdc5b483b 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 098ce80500..31afe1ebfc 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 ee88e2a877..4a1850ae28 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. */ + * '?' 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. */ - 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; - } - - 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 ee1c932d28..802f7c9f20 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 e3fc8a220b..0ac0acd475 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 7ff9ca8531..c1238f83a8 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