diff --git a/changes/anyone-hosts-autoupdate b/changes/anyone-hosts-autoupdate new file mode 100644 index 0000000000..eb46e87181 --- /dev/null +++ b/changes/anyone-hosts-autoupdate @@ -0,0 +1,13 @@ + o New features (anyone-hosts auto-update): + - The client now automatically fetches a fresh copy of the anyone_hosts + DNS mapping file from the .anyone DNS service nodes when new directory + information arrives and/or on a periodic schedule. New configuration + options AnyoneHostsUpdate, AnyoneHostsURL, AnyoneHostsUpdateInterval, + AnyoneHostsFetchPath, AnyoneHostsUpdateTrigger, and + AnyoneHostsSignatureRequirement control the behaviour. + + o Minor features (documentation): + - DNSMappingFileMaxSize now documents bit-based units (KBits, MBits, + GBits, TBits) in the man page, consistent with other MEMUNIT options. + - Clarified that DNSMappingFileMaxSize only limits loading an existing + file; a missing file is always recreated with the default mapping. diff --git a/doc/man/anon.1.txt b/doc/man/anon.1.txt index 77fb1afd0e..f726335f9f 100644 --- a/doc/man/anon.1.txt +++ b/doc/man/anon.1.txt @@ -1222,10 +1222,48 @@ The following options are useful only for clients (that is, if addresses/ports. See <> for an explanation of isolation flags. (Default: 0) -[[DNSMappingFileMaxSize]] **DNSMappingFileMaxSize** __N__ **bytes**|**KBytes**|**MBytes**|**GBytes**|**TBytes**:: +[[DNSMappingFileMaxSize]] **DNSMappingFileMaxSize** __N__ **bytes**|**KBytes**|**MBytes**|**GBytes**|**TBytes**|**KBits**|**MBits**|**GBits**|**TBits**:: The maximum size allowed for the `anyone_hosts` DNS mapping file in the data directory. If the file exceeds this size, it is rejected. Set this - option to 0 to disable the size limit. (Default: 10 MB) + option to 0 to disable the size limit. This limit applies only when + reading or accepting an existing file; if the file is absent, a new + default file is created regardless of this setting. (Default: 10 MB) + +[[AnyoneHostsUpdate]] **AnyoneHostsUpdate** **0**|**1**:: + If 1, the client will automatically attempt to fetch a fresh copy of + the `anyone_hosts` DNS mapping file from the .anyone DNS service nodes + when new directory information arrives and/or on a periodic schedule + (see <>). + (Default: 1) + +[[AnyoneHostsURL]] **AnyoneHostsURL** __address__:: + A .anyone onion address of a DNS service node to try when fetching the + `anyone_hosts` file. This option may be specified multiple times; the + addresses are tried in order before the built-in defaults extracted from + the current `anyone_hosts` file and `DEFAULT_ANON_DNS_MAPPING`. + (Default: none) + +[[AnyoneHostsUpdateInterval]] **AnyoneHostsUpdateInterval** __N__ **seconds**|**minutes**|**hours**|**days**|**weeks**:: + How often to attempt fetching a fresh `anyone_hosts` file. + (Default: 12 hours) + +[[AnyoneHostsFetchPath]] **AnyoneHostsFetchPath** __path__:: + The HTTP resource path to request from the DNS service node. + (Default: /tld/anyone) + +[[AnyoneHostsUpdateTrigger]] **AnyoneHostsUpdateTrigger** **consensus**|**periodic**|**both**:: + Controls what events trigger an `anyone_hosts` fetch attempt. **consensus** + triggers a fetch only when a new network consensus is successfully loaded. + **periodic** triggers only via the periodic scheduler. **both** uses + either trigger. (Default: both) + +[[AnyoneHostsSignatureRequirement]] **AnyoneHostsSignatureRequirement** **strict**|**verify**|**any**:: + Controls how strictly the signature on a fetched `anyone_hosts` file is + checked. **strict** requires a valid signature from a trusted DNS signer; + unsigned or untrusted files are discarded. **verify** accepts unsigned + files but rejects files whose signature fails verification. **any** accepts + all files that parse without error, regardless of signature status. + (Default: strict) [[DownloadExtraInfo]] **DownloadExtraInfo** **0**|**1**:: If true, Tor downloads and caches "extra-info" documents. These documents diff --git a/src/app/config/config.c b/src/app/config/config.c index c8291d6fff..e01320e886 100644 --- a/src/app/config/config.c +++ b/src/app/config/config.c @@ -327,6 +327,12 @@ static const config_var_t option_vars_[] = { V(AlternateBridgeAuthority, LINELIST, NULL), V(AlternateDirAuthority, LINELIST, NULL), OBSOLETE("AlternateHSAuthority"), + V(AnyoneHostsFetchPath, STRING, "/tld/anyone"), + V(AnyoneHostsSignatureRequirement, STRING, "strict"), + V(AnyoneHostsUpdate, BOOL, "1"), + V(AnyoneHostsUpdateInterval, INTERVAL, "12 hours"), + V(AnyoneHostsUpdateTrigger, STRING, "both"), + V(AnyoneHostsURL, LINELIST, NULL), V(AssumeReachable, BOOL, "0"), V(AssumeReachableIPv6, AUTOBOOL, "auto"), OBSOLETE("AuthDirBadDir"), @@ -4062,6 +4068,49 @@ options_validate_cb(const void *old_options_, void *options_, char **msg) return -1; } + if (options->AnyoneHostsUpdateTrigger) { + const char *t = options->AnyoneHostsUpdateTrigger; + if (strcmp(t, "consensus") && strcmp(t, "periodic") && strcmp(t, "both")) { + REJECT("AnyoneHostsUpdateTrigger must be \"consensus\", " + "\"periodic\", or \"both\"."); + } + } + if (options->AnyoneHostsURL) { + const config_line_t *cl; + for (cl = options->AnyoneHostsURL; cl; cl = cl->next) { + const char *v = cl->value; + if (!v || !strlen(v) || strcmpend(v, ".anyone") || + strstr(v, "://") || strchr(v, '/') || strchr(v, ':') || + strpbrk(v, " \t\r\n")) { + REJECT("AnyoneHostsURL entries must be bare .anyone hostnames " + "(no scheme, port, path, or whitespace) ending in " + "\".anyone\"."); + } + } + } + if (options->AnyoneHostsSignatureRequirement) { + const char *s = options->AnyoneHostsSignatureRequirement; + if (strcmp(s, "strict") && strcmp(s, "verify") && strcmp(s, "any")) { + REJECT("AnyoneHostsSignatureRequirement must be \"strict\", " + "\"verify\", or \"any\"."); + } + } + if (options->AnyoneHostsUpdateInterval < 1) { + REJECT("AnyoneHostsUpdateInterval must be positive."); + } + if (!options->AnyoneHostsFetchPath || + !strlen(options->AnyoneHostsFetchPath) || + options->AnyoneHostsFetchPath[0] != '/') { + REJECT("AnyoneHostsFetchPath must be a non-empty absolute path " + "beginning with '/'."); + } + /* The path is inserted verbatim into the HTTP request line, so reject any + * whitespace or CR/LF that could break the request or smuggle headers. */ + if (strpbrk(options->AnyoneHostsFetchPath, " \t\r\n")) { + REJECT("AnyoneHostsFetchPath must not contain whitespace or " + "newline characters."); + } + return 0; } diff --git a/src/app/config/or_options_st.h b/src/app/config/or_options_st.h index 72ea13a9dd..e98d032424 100644 --- a/src/app/config/or_options_st.h +++ b/src/app/config/or_options_st.h @@ -705,6 +705,21 @@ struct or_options_t { /** Maximum size in bytes allowed for the anyone_hosts DNS mapping file. * A value of 0 disables the size limit. */ uint64_t DNSMappingFileMaxSize; + /** If true, automatically update the anyone_hosts DNS mapping file when + * new directory information arrives or on a periodic schedule. */ + int AnyoneHostsUpdate; + /** List of .anyone service addresses to try when fetching the + * anyone_hosts file. Tried in order before built-in defaults. */ + struct config_line_t *AnyoneHostsURL; + /** How often (in seconds) to check for a new anyone_hosts file. */ + int AnyoneHostsUpdateInterval; + /** HTTP resource path on the DNS service to fetch. */ + char *AnyoneHostsFetchPath; + /** When to trigger an update: "consensus", "periodic", or "both". */ + char *AnyoneHostsUpdateTrigger; + /** Signature acceptance policy: "strict" (valid sig only), "verify" + * (valid sig or unsigned), or "any" (accept unless parse error). */ + char *AnyoneHostsSignatureRequirement; /** If true, do not accept any requests to connect to internal addresses * over randomly chosen exits. */ diff --git a/src/core/mainloop/mainloop.c b/src/core/mainloop/mainloop.c index a8c00f0a49..2da48c8b01 100644 --- a/src/core/mainloop/mainloop.c +++ b/src/core/mainloop/mainloop.c @@ -67,6 +67,7 @@ #include "core/or/connection_or.h" #include "core/or/dos.h" #include "core/or/status.h" +#include "feature/anyone/anyone_hosts_update.h" #include "feature/client/addressmap.h" #include "feature/client/bridges.h" #include "feature/client/dnsserv.h" @@ -1381,6 +1382,7 @@ CALLBACK(write_stats_file); CALLBACK(control_per_second_events); CALLBACK(second_elapsed); CALLBACK(manage_vglite); +CALLBACK(update_anyone_hosts); #undef CALLBACK @@ -1406,6 +1408,9 @@ STATIC periodic_event_item_t mainloop_periodic_events[] = { /* Update vanguards-lite once per hour, if we have networking */ CALLBACK(manage_vglite, NET_PARTICIPANT, FL(NEED_NET)), + /* Periodically update the anyone_hosts DNS mapping file. */ + CALLBACK(update_anyone_hosts, CLIENT, FL(NEED_NET)), + /* XXXX Do we have a reason to do this on a callback? Does it do any good at * all? For now, if we're dormant, we can let our listeners decay. */ CALLBACK(retry_listeners, NET_PARTICIPANT, FL(NEED_NET)), @@ -1553,6 +1558,8 @@ initialize_periodic_events(void) NAMED_CALLBACK(launch_descriptor_fetches); NAMED_CALLBACK(check_dns_honesty); NAMED_CALLBACK(save_state); + + anyone_hosts_update_init(); } STATIC void @@ -1691,6 +1698,21 @@ manage_vglite_callback(time_t now, const or_options_t *options) return VANGUARDS_LITE_INTERVAL; } +/** Periodic-event callback: attempt to update the anyone_hosts DNS mapping + * file, and return the number of seconds until the next run + * (AnyoneHostsUpdateInterval). + */ +static int +update_anyone_hosts_callback(time_t now, const or_options_t *options) +{ + /* Relays can also have the CLIENT role (e.g. when ControlPort is set), but + * anyone_hosts auto-update is intended for clients only. */ + if (server_mode(options)) + return options->AnyoneHostsUpdateInterval; + + return anyone_hosts_update_callback(now, options); +} + /** Perform regular maintenance tasks. This function gets run once per * second. */ diff --git a/src/core/or/connection_edge.c b/src/core/or/connection_edge.c index 7847f95117..01cbfd8938 100644 --- a/src/core/or/connection_edge.c +++ b/src/core/or/connection_edge.c @@ -1817,8 +1817,12 @@ bool lookup_anon_dns_mapping(const char *anon_address, char *onion_address_out, } const or_options_t *options = get_options(); const uint64_t max_size_opt = options->DNSMappingFileMaxSize; - const size_t max_size = max_size_opt == 0 ? SIZE_T_CEILING : - (max_size_opt > SIZE_T_CEILING ? SIZE_T_CEILING : (size_t)max_size_opt); + /* read_file_to_str_until_eof() rejects a limit of SIZE_T_CEILING or more, so + * keep the effective "no cap" value just below that boundary; otherwise a + * cap of 0 (unlimited) would make the read fail and the mapping be ignored. */ + const size_t max_read_cap = SIZE_T_CEILING - 2; + const size_t max_size = (max_size_opt == 0 || max_size_opt > max_read_cap) + ? max_read_cap : (size_t)max_size_opt; size_t file_sz = 0; file_content = read_file_to_str_until_eof(dns_fd, max_size, &file_sz); close(dns_fd); diff --git a/src/feature/anyone/anyone_hosts_update.c b/src/feature/anyone/anyone_hosts_update.c new file mode 100644 index 0000000000..fffdb92da5 --- /dev/null +++ b/src/feature/anyone/anyone_hosts_update.c @@ -0,0 +1,314 @@ +/* Copyright (c) 2007-2021, The Tor Project, Inc. */ +/* See LICENSE for licensing information */ + +/** + * \file anyone_hosts_update.c + * \brief Periodic and consensus-triggered fetching of the anyone_hosts DNS + * mapping file from the .anyone DNS service nodes. + * + * When the client receives a fresh consensus, or on a periodic schedule, + * this module selects a URL from the configured list (AnyoneHostsURL) plus + * the DNS service addresses found in the currently loaded anyone_hosts file + * and the hardcoded DEFAULT_ANON_DNS_MAPPING, then issues an anonymised + * HTTP GET for AnyoneHostsFetchPath. The response is handled by + * handle_response_fetch_anyone_hosts() in dirclient.c, which verifies the + * signature and atomically writes the file if acceptable. + * + * The fetch is only launched when: + * - AnyoneHostsUpdate is set to 1, AND + * - no fetch is already in progress, AND + * - at least AnyoneHostsUpdateInterval seconds have elapsed since the last + * successful fetch (and we additionally wait at least + * ANYONE_HOSTS_MIN_RETRY_INTERVAL between attempts before the first + * success). + **/ + +#include "core/or/or.h" +#include "feature/anyone/anyone_hosts_update.h" +#include "feature/dircommon/directory.h" +#include "feature/dirclient/dirclient.h" +#include "app/config/config.h" +#include "lib/log/log.h" +#include "lib/malloc/malloc.h" +#include "lib/container/smartlist.h" +#include "lib/string/util_string.h" +#include "lib/encoding/confline.h" +#include "lib/fs/files.h" + +#ifdef HAVE_UNISTD_H +#include +#endif +#ifdef HAVE_FCNTL_H +#include +#endif + +/** Minimum gap between consecutive fetch *attempts* (seconds). */ +#define ANYONE_HOSTS_MIN_RETRY_INTERVAL 3600 + +/** If a fetch has been "in progress" for at least this many seconds without + * reporting a result, assume it failed on a path that did not notify us and + * clear the flag so future updates are not blocked. */ +#define ANYONE_HOSTS_FETCH_TIMEOUT 600 + +/** True while a DIR_PURPOSE_FETCH_ANYONE_HOSTS connection is open. */ +static int fetch_in_progress = 0; + +/** Wall-clock time of the last fetch attempt. */ +static time_t last_attempt_time = 0; + +/** Wall-clock time of the last *successful* fetch. */ +static time_t last_success_time = 0; + +/** Round-robin index into the URL list. */ +static int current_url_index = 0; + +/** ---------- helpers ---------- */ + +/** + * Build an ordered smartlist of onion-address strings to try for fetching + * the anyone_hosts file. Caller must free each element and the list. + * + * Order: AnyoneHostsURL config entries (user overrides) first, then the + * right-hand-side addresses from the currently saved anyone_hosts file, + * then addresses from DEFAULT_ANON_DNS_MAPPING. Duplicates are kept so + * that the round-robin stays predictable. + */ +static smartlist_t * +anyone_hosts_get_url_list(void) +{ + smartlist_t *urls = smartlist_new(); + const or_options_t *options = get_options(); + + /* 1. User-configured overrides. */ + for (const config_line_t *cl = options->AnyoneHostsURL; cl; cl = cl->next) { + if (cl->value && strlen(cl->value)) + smartlist_add(urls, tor_strdup(cl->value)); + } + + /* Helper: parse lines of the form " " and + * add the onion address to . Only accept right-hand-side tokens + * that look like .anyone addresses, and skip metadata lines from the + * signed file format (e.g. "anyone-hosts-version 1", + * "anyone-hosts-digest sha256 ...", "anyone-hosts-signature ") + * whose first token starts with "anyone-hosts-" -- even if their second + * token happens to end in ".anyone". */ +#define ADD_MAPPING_LINES(text) \ + do { \ + smartlist_t *_lines = smartlist_new(); \ + char *_copy = tor_strdup(text); \ + smartlist_split_string(_lines, _copy, "\n", SPLIT_SKIP_SPACE, 0); \ + SMARTLIST_FOREACH_BEGIN(_lines, const char *, _line) { \ + if (strcmpstart(_line, "anyone-hosts-") == 0) \ + continue; \ + /* Find the whitespace separating the name from the onion \ + * address, accepting either spaces or tabs to match the \ + * sscanf("%s %s") tokenisation used by the lookup parser. */ \ + const char *_sp = _line; \ + while (*_sp && *_sp != ' ' && *_sp != '\t') \ + _sp++; \ + while (*_sp == ' ' || *_sp == '\t') \ + _sp++; \ + if (*_sp) { \ + const char *_addr = _sp; \ + size_t _alen = strlen(_addr); \ + if (_alen >= 7 && !strcmp(_addr + _alen - 7, ".anyone")) \ + smartlist_add(urls, tor_strdup(_addr)); \ + } \ + } SMARTLIST_FOREACH_END(_line); \ + SMARTLIST_FOREACH(_lines, char *, _s, tor_free(_s)); \ + smartlist_free(_lines); \ + tor_free(_copy); \ + } while (0) + + /* 2. Addresses from the currently saved anyone_hosts file. Cap the read + * at DNSMappingFileMaxSize so a large or hostile local file cannot cause + * excessive memory use here, matching the cap that lookups use. */ + char *hosts_fname = get_datadir_fname("anyone_hosts"); + int hosts_fd = tor_open_cloexec(hosts_fname, O_RDONLY, 0); + tor_free(hosts_fname); + if (hosts_fd >= 0) { + const uint64_t max_size_opt = options->DNSMappingFileMaxSize; + /* read_file_to_str_until_eof() rejects a limit of SIZE_T_CEILING or more, + * so keep the effective "no cap" value just below that boundary; + * otherwise a cap of 0 (unlimited) would make the read fail and the saved + * addresses be silently skipped. */ + const size_t max_read_cap = SIZE_T_CEILING - 2; + const size_t max_size = (max_size_opt == 0 || max_size_opt > max_read_cap) + ? max_read_cap : (size_t)max_size_opt; + size_t hosts_sz = 0; + char *hosts_content = + read_file_to_str_until_eof(hosts_fd, max_size, &hosts_sz); + close(hosts_fd); + if (hosts_content) { + ADD_MAPPING_LINES(hosts_content); + tor_free(hosts_content); + } + } + + /* 3. Hardcoded defaults as a last resort. */ + ADD_MAPPING_LINES(DEFAULT_ANON_DNS_MAPPING); + +#undef ADD_MAPPING_LINES + + return urls; +} + +/** ---------- public API ---------- */ + +void +anyone_hosts_update_init(void) +{ + fetch_in_progress = 0; + last_attempt_time = 0; + last_success_time = 0; + current_url_index = 0; +} + +void +anyone_hosts_update_free_all(void) +{ + /* Reset all module state so a later re-init (e.g. in tests or a future + * reload path) starts from a clean slate, not just the in-progress flag. */ + fetch_in_progress = 0; + last_attempt_time = 0; + last_success_time = 0; + current_url_index = 0; +} + +/** Called by dirclient when a DIR_PURPOSE_FETCH_ANYONE_HOSTS connection + * completes (successfully or not). This clears the in-progress flag and, + * on success, records the current time so the interval timer resets. */ +void +anyone_hosts_update_note_result(int success, time_t now) +{ + /* Be idempotent within a single fetch: the response handler and the + * connection-failure path can both call this, so only the first call for a + * given fetch records a result (and advances the round-robin index). */ + if (!fetch_in_progress) + return; + fetch_in_progress = 0; + if (success) + last_success_time = now; + /* Advance the index so the next attempt tries a different server, whether + * or not this one succeeded. This keeps the ordered fallback / round-robin + * working even when the first entry is permanently unreachable. */ + current_url_index++; +} + +/** + * Launch one fetch if conditions are met. Called both from the consensus + * hook (anyone_hosts_update_maybe_kick) and from the periodic callback. + */ +static void +maybe_launch_fetch(time_t now) +{ + const or_options_t *options = get_options(); + + if (!options->AnyoneHostsUpdate) + return; + if (fetch_in_progress) { + /* Safety net: a previous fetch may have failed on a path that never + * called anyone_hosts_update_note_result(). Don't stay stuck forever. + * Record it as a failed attempt and return; the next periodic/consensus + * trigger will start a fresh fetch. (Don't fall through to launch a new + * fetch here: the original connection may still be live and could later + * call note_result(), clobbering the new fetch's state.) */ + if (last_attempt_time && + (now - last_attempt_time) >= ANYONE_HOSTS_FETCH_TIMEOUT) { + log_info(LD_DIR, "anyone_hosts fetch appears stuck; treating as failed."); + anyone_hosts_update_note_result(0, now); + } + return; + } + + /* Respect the configured update interval for successes. */ + if (last_success_time && + (now - last_success_time) < options->AnyoneHostsUpdateInterval) + return; + + /* After a failed attempt wait at least ANYONE_HOSTS_MIN_RETRY_INTERVAL + * before trying again, regardless of the configured interval. We treat + * the last attempt as a failure if it happened after the last success, so + * the backoff also applies to failures that occur after an earlier success + * (preventing a rapid retry storm). */ + if (last_attempt_time && last_attempt_time > last_success_time && + (now - last_attempt_time) < ANYONE_HOSTS_MIN_RETRY_INTERVAL) + return; + + /* Pick the next URL from the list. */ + smartlist_t *urls = anyone_hosts_get_url_list(); + if (smartlist_len(urls) == 0) { + log_info(LD_DIR, "anyone_hosts update: no URLs available."); + SMARTLIST_FOREACH(urls, char *, u, tor_free(u)); + smartlist_free(urls); + return; + } + + int idx = current_url_index % smartlist_len(urls); + const char *onion_addr = smartlist_get(urls, idx); + + log_info(LD_DIR, "Launching anyone_hosts fetch from %s", + safe_str(onion_addr)); + + /* Build and fire the directory request. The connection is anonymised + * (purpose_needs_anonymity returns 1 for DIR_PURPOSE_FETCH_ANYONE_HOSTS) + * and tunnelled through a 3-hop circuit to the .anyone DNS service, which + * serves the file over plain HTTP on port 80. */ + directory_request_t *req = + directory_request_new(DIR_PURPOSE_FETCH_ANYONE_HOSTS); + directory_request_set_indirection(req, DIRIND_ANONYMOUS); + + /* Route to the onion address by name rather than by IP. We set the + * dir-port to 80 (with no or-port) so the request is sent as a plain + * anonymised stream rather than a begindir tunnel, and supply the + * .anyone address explicitly. */ + tor_addr_port_t dirport; + memset(&dirport, 0, sizeof(dirport)); + tor_addr_make_null(&dirport.addr, AF_INET); + dirport.port = 80; + directory_request_set_dir_addr_port(req, &dirport); + directory_request_set_anon_onion_address(req, onion_addr); + + /* The HTTP resource (path) to request from the DNS service. */ + directory_request_set_resource(req, options->AnyoneHostsFetchPath); + + /* A directory request requires an identity digest; it is unused for an + * anonymised onion-address fetch, so pass a zero placeholder. */ + static const char zero_digest[DIGEST_LEN] = {0}; + directory_request_set_directory_id_digest(req, zero_digest); + + fetch_in_progress = 1; + last_attempt_time = now; + + directory_initiate_request(req); + directory_request_free(req); + + SMARTLIST_FOREACH(urls, char *, u, tor_free(u)); + smartlist_free(urls); +} + +void +anyone_hosts_update_maybe_kick(time_t now) +{ + const or_options_t *options = get_options(); + if (!options->AnyoneHostsUpdateTrigger) + return; + const char *t = options->AnyoneHostsUpdateTrigger; + if (strcmp(t, "consensus") != 0 && strcmp(t, "both") != 0) + return; + + maybe_launch_fetch(now); +} + +int +anyone_hosts_update_callback(time_t now, const or_options_t *options) +{ + if (!options->AnyoneHostsUpdateTrigger) + return options->AnyoneHostsUpdateInterval; + const char *t = options->AnyoneHostsUpdateTrigger; + if (strcmp(t, "periodic") != 0 && strcmp(t, "both") != 0) + return options->AnyoneHostsUpdateInterval; + + maybe_launch_fetch(now); + return options->AnyoneHostsUpdateInterval; +} diff --git a/src/feature/anyone/anyone_hosts_update.h b/src/feature/anyone/anyone_hosts_update.h new file mode 100644 index 0000000000..ccda70a160 --- /dev/null +++ b/src/feature/anyone/anyone_hosts_update.h @@ -0,0 +1,35 @@ +/* Copyright (c) 2007-2021, The Tor Project, Inc. */ +/* See LICENSE for licensing information */ + +/** + * \file anyone_hosts_update.h + * \brief Header for anyone_hosts_update.c + * + * Periodic and consensus-triggered fetching of the anyone_hosts DNS + * mapping file from the .anyone DNS service nodes. + **/ + +#ifndef TOR_ANYONE_HOSTS_UPDATE_H +#define TOR_ANYONE_HOSTS_UPDATE_H + +#include "lib/testsupport/testsupport.h" + +/** Initialize the anyone_hosts update subsystem. */ +void anyone_hosts_update_init(void); + +/** Free any state held by the anyone_hosts update subsystem. */ +void anyone_hosts_update_free_all(void); + +/** Called after a consensus is successfully loaded; may kick off a fetch + * if the configuration and timing allow it. */ +void anyone_hosts_update_maybe_kick(time_t now); + +/** Called by dirclient when a DIR_PURPOSE_FETCH_ANYONE_HOSTS fetch + * completes. success is 1 if the file was saved, 0 otherwise. */ +void anyone_hosts_update_note_result(int success, time_t now); + +/** Periodic-event callback: try to fetch a fresh anyone_hosts file. */ +int anyone_hosts_update_callback(time_t now, + const struct or_options_t *options); + +#endif /* !defined(TOR_ANYONE_HOSTS_UPDATE_H) */ diff --git a/src/feature/anyone/include.am b/src/feature/anyone/include.am new file mode 100644 index 0000000000..bba6c92acf --- /dev/null +++ b/src/feature/anyone/include.am @@ -0,0 +1,8 @@ + +# ADD_C_FILE: INSERT SOURCES HERE. +LIBANON_APP_A_SOURCES += \ + src/feature/anyone/anyone_hosts_update.c + +# ADD_C_FILE: INSERT HEADERS HERE. +noinst_HEADERS += \ + src/feature/anyone/anyone_hosts_update.h diff --git a/src/feature/dirclient/dirclient.c b/src/feature/dirclient/dirclient.c index 5bd6ab3cba..ab6b09efa2 100644 --- a/src/feature/dirclient/dirclient.c +++ b/src/feature/dirclient/dirclient.c @@ -47,8 +47,11 @@ #include "feature/relay/relay_find_addr.h" #include "feature/relay/routermode.h" #include "feature/relay/selftest.h" +#include "feature/anyone/anyone_hosts_update.h" +#include "feature/dirparse/anyone_hosts_parse.h" #include "feature/rend/rendcommon.h" #include "feature/stats/predict_ports.h" +#include "lib/fs/files.h" #include "lib/cc/ctassert.h" #include "lib/compress/compress.h" @@ -122,6 +125,8 @@ dir_conn_purpose_to_string(int purpose) return "hidden-service descriptor upload"; case DIR_PURPOSE_FETCH_MICRODESC: return "microdescriptor fetch"; + case DIR_PURPOSE_FETCH_ANYONE_HOSTS: + return "anyone-hosts fetch"; } log_warn(LD_BUG, "Called with unknown purpose %d", purpose); @@ -160,6 +165,9 @@ dir_fetch_type(int dir_purpose, int router_purpose, const char *resource) case DIR_PURPOSE_FETCH_MICRODESC: type = MICRODESC_DIRINFO; break; + case DIR_PURPOSE_FETCH_ANYONE_HOSTS: + type = NO_DIRINFO; + break; default: log_warn(LD_BUG, "Unexpected purpose %d", (int)dir_purpose); type = NO_DIRINFO; @@ -768,6 +776,13 @@ connection_dir_client_request_failed(dir_connection_t *conn) log_warn(LD_DIR, "Failed to post %s to %s.", dir_conn_purpose_to_string(conn->base_.purpose), connection_describe_peer(TO_CONN(conn))); + } else if (conn->base_.purpose == DIR_PURPOSE_FETCH_ANYONE_HOSTS) { + /* The fetch failed before we got a response (connect/circuit/timeout + * error, etc.). Notify the updater so it clears its in-progress flag + * and can retry; the success path is handled in the response handler. */ + log_info(LD_DIR, "anyone_hosts fetch from %s failed.", + connection_describe_peer(TO_CONN(conn))); + anyone_hosts_update_note_result(0, time(NULL)); } } @@ -1048,6 +1063,17 @@ directory_request_set_resource(directory_request_t *req, { req->resource = resource; } +/** + * Set an onion (.anyone) address to route this request to by name instead of + * by IP address. Used for anyone_hosts auto-update fetches. Note that only + * an alias to address is stored, so it must outlive the request. + */ +void +directory_request_set_anon_onion_address(directory_request_t *req, + const char *address) +{ + req->anon_onion_address = address; +} /** * Set a pointer to the payload to include with this directory request, along * with its length. Note that only an alias to payload is stored, so @@ -1269,6 +1295,7 @@ directory_initiate_request,(directory_request_t *request)) const dir_indirection_t indirection = request->indirection; const char *resource = request->resource; const hs_ident_dir_conn_t *hs_ident = request->hs_ident; + const char *anon_onion_address = request->anon_onion_address; circuit_guard_state_t *guard_state = request->guard_state; tor_assert(or_addr_port->port || dir_addr_port->port); @@ -1308,7 +1335,8 @@ directory_initiate_request,(directory_request_t *request)) /* use encrypted begindir connections for everything except relays * this provides better protection for directory fetches */ - if (!use_begindir && dirclient_must_use_begindir(options)) { + if (!use_begindir && !anon_onion_address && + dirclient_must_use_begindir(options)) { log_warn(LD_BUG, "Client could not use begindir connection: %s", begindir_reason ? begindir_reason : "(NULL)"); return; @@ -1324,7 +1352,17 @@ directory_initiate_request,(directory_request_t *request)) } /* Make sure that the destination addr and port we picked is viable. */ - if (!port || tor_addr_is_null(&addr)) { + if (anon_onion_address) { + /* We're routing to an onion service by name through an anonymised + * circuit; we don't have (or need) a numeric address, but we do need a + * port and an anonymised connection. */ + tor_assert(anonymized_connection); + if (!port) { + log_warn(LD_DIR, "Cannot fetch from onion service %s without a port.", + safe_str(anon_onion_address)); + return; + } + } else if (!port || tor_addr_is_null(&addr)) { static int logged_backtrace = 0; log_warn(LD_DIR, "Cannot make an outgoing %sconnection without a remote %sPort.", @@ -1342,7 +1380,8 @@ directory_initiate_request,(directory_request_t *request)) /* set up conn so it's got all the data we need to remember */ tor_addr_copy(&conn->base_.addr, &addr); conn->base_.port = port; - conn->base_.address = tor_addr_to_str_dup(&addr); + conn->base_.address = anon_onion_address ? tor_strdup(anon_onion_address) + : tor_addr_to_str_dup(&addr); memcpy(conn->identity_digest, digest, DIGEST_LEN); conn->base_.purpose = dir_purpose; @@ -1705,6 +1744,12 @@ directory_send_command(dir_connection_t *conn, httpcommand = "POST"; tor_asprintf(&url, "/tor/hs/%s/publish", resource); break; + case DIR_PURPOSE_FETCH_ANYONE_HOSTS: + tor_assert(resource); + tor_assert(!payload); + httpcommand = "GET"; + url = tor_strdup(resource); + break; default: tor_assert(0); return; @@ -1844,6 +1889,8 @@ static int handle_response_upload_signatures(dir_connection_t *, const response_handler_args_t *); static int handle_response_upload_hsdesc(dir_connection_t *, const response_handler_args_t *); +static int handle_response_fetch_anyone_hosts(dir_connection_t *, + const response_handler_args_t *); static int dir_client_decompress_response_body(char **bodyp, size_t *bodylenp, @@ -1971,7 +2018,7 @@ dir_client_decompress_response_body(char **bodyp, size_t *bodylenp, * (For example, the number of bytes downloaded of purpose p while * not fully bootstrapped is total_dl[p][false].) **/ -static uint64_t total_dl[DIR_PURPOSE_MAX_][2]; +static uint64_t total_dl[DIR_PURPOSE_MAX_ + 1][2]; /** * Heartbeat: dump a summary of how many bytes of which purpose we've @@ -1983,7 +2030,7 @@ dirclient_dump_total_dls(void) const or_options_t *options = get_options(); for (int bootstrapped = 0; bootstrapped < 2; ++bootstrapped) { smartlist_t *lines = smartlist_new(); - for (int i=0; i < DIR_PURPOSE_MAX_; ++i) { + for (int i=0; i <= DIR_PURPOSE_MAX_; ++i) { uint64_t n = total_dl[i][bootstrapped]; if (n == 0) continue; @@ -2204,6 +2251,9 @@ connection_dir_client_reached_eof(dir_connection_t *conn) case DIR_PURPOSE_FETCH_HSDESC: rv = handle_response_fetch_hsdesc_v3(conn, &args); break; + case DIR_PURPOSE_FETCH_ANYONE_HOSTS: + rv = handle_response_fetch_anyone_hosts(conn, &args); + break; default: tor_assert_nonfatal_unreached(); rv = -1; @@ -2325,6 +2375,8 @@ handle_response_fetch_consensus(dir_connection_t *conn, routers_update_all_from_networkstatus(now, 3); update_microdescs_from_networkstatus(now); directory_info_has_arrived(now, 0, 0); + if (!server_mode(get_options())) + anyone_hosts_update_maybe_kick(now); if (authdir_mode_v3(get_options())) { sr_act_post_consensus( @@ -2842,6 +2894,97 @@ handle_response_upload_hsdesc(dir_connection_t *conn, return 0; } +/** + * Handler function: processes a response to a request to fetch the + * anyone_hosts DNS mapping file from a .anyone DNS service node. + **/ +static int +handle_response_fetch_anyone_hosts(dir_connection_t *conn, + const response_handler_args_t *args) +{ + tor_assert(conn->base_.purpose == DIR_PURPOSE_FETCH_ANYONE_HOSTS); + const int status_code = args->status_code; + const char *reason = args->reason; + const char *body = args->body; + const size_t body_len = args->body_len; + const or_options_t *options = get_options(); + const time_t now = approx_time(); + int success = 0; + + if (status_code != 200) { + log_info(LD_DIR, + "Received http status code %d (%s) from server " + "%s while fetching anyone_hosts file.", + status_code, escaped(reason), + connection_describe_peer(TO_CONN(conn))); + anyone_hosts_update_note_result(0, now); + return -1; + } + + /* Enforce the configured size limit. */ + if (options->DNSMappingFileMaxSize > 0 && + body_len > options->DNSMappingFileMaxSize) { + log_warn(LD_DIR, "anyone_hosts file from %s is too large (%"TOR_PRIuSZ + " bytes, limit %"PRIu64"); discarding.", + connection_describe_peer(TO_CONN(conn)), + body_len, options->DNSMappingFileMaxSize); + anyone_hosts_update_note_result(0, now); + return -1; + } + + /* Verify signature according to the configured policy. */ + anyone_hosts_sig_status_t sig = anyone_hosts_parse_and_verify(body, body_len); + const char *sig_req = options->AnyoneHostsSignatureRequirement; + if (!sig_req) sig_req = "strict"; + + if (strcmp(sig_req, "strict") == 0) { + if (sig != ANYONE_HOSTS_SIG_VALID) { + log_warn(LD_DIR, "anyone_hosts file from %s: signature check failed " + "(status %d); discarding (strict mode).", + connection_describe_peer(TO_CONN(conn)), (int)sig); + anyone_hosts_update_note_result(0, now); + return -1; + } + } else if (strcmp(sig_req, "verify") == 0) { + if (sig == ANYONE_HOSTS_SIG_INVALID || + sig == ANYONE_HOSTS_SIG_BAD_SIGNER || + sig == ANYONE_HOSTS_SIG_PARSE_ERROR) { + log_warn(LD_DIR, "anyone_hosts file from %s: signature invalid " + "(status %d); discarding (verify mode).", + connection_describe_peer(TO_CONN(conn)), (int)sig); + anyone_hosts_update_note_result(0, now); + return -1; + } + } else { + /* "any" — only reject outright parse errors */ + if (sig == ANYONE_HOSTS_SIG_PARSE_ERROR) { + log_warn(LD_DIR, "anyone_hosts file from %s: parse error; discarding.", + connection_describe_peer(TO_CONN(conn))); + anyone_hosts_update_note_result(0, now); + return -1; + } + } + + /* write_bytes_to_file() writes atomically via its own temp file + rename, + * so write the verified bytes straight to the final path. */ + char *hosts_fname = get_datadir_fname("anyone_hosts"); + int write_ok = + (write_bytes_to_file(hosts_fname, body, body_len, 1) == 0); + if (!write_ok) { + log_warn(LD_FS, "Error writing anyone_hosts file."); + } + tor_free(hosts_fname); + + if (write_ok) { + log_info(LD_DIR, "Successfully updated anyone_hosts file (%"TOR_PRIuSZ + " bytes).", body_len); + success = 1; + } + + anyone_hosts_update_note_result(success, now); + return success ? 0 : -1; +} + /** Called when a directory connection reaches EOF. */ int connection_dir_reached_eof(dir_connection_t *conn) diff --git a/src/feature/dirclient/dirclient.h b/src/feature/dirclient/dirclient.h index f233fa70d2..d3913001de 100644 --- a/src/feature/dirclient/dirclient.h +++ b/src/feature/dirclient/dirclient.h @@ -69,6 +69,8 @@ void directory_request_set_indirection(directory_request_t *req, dir_indirection_t indirection); void directory_request_set_resource(directory_request_t *req, const char *resource); +void directory_request_set_anon_onion_address(directory_request_t *req, + const char *address); void directory_request_set_payload(directory_request_t *req, const char *payload, size_t payload_len); @@ -117,6 +119,10 @@ struct directory_request_t { dir_indirection_t indirection; /** Alias to the variable part of the URL for this request */ const char *resource; + /** If set, an onion (.anyone) address to route this request to by name + * instead of by IP. Used for anyone_hosts auto-update fetches; only an + * alias is stored, so the string must outlive the request. */ + const char *anon_onion_address; /** Alias to the payload to upload (if any) */ const char *payload; /** Number of bytes to upload from payload */ diff --git a/src/feature/dircommon/directory.c b/src/feature/dircommon/directory.c index 6614bb065e..393c3a6f22 100644 --- a/src/feature/dircommon/directory.c +++ b/src/feature/dircommon/directory.c @@ -144,6 +144,7 @@ purpose_needs_anonymity(uint8_t dir_purpose, uint8_t router_purpose, case DIR_PURPOSE_HAS_FETCHED_HSDESC: case DIR_PURPOSE_FETCH_HSDESC: case DIR_PURPOSE_UPLOAD_HSDESC: + case DIR_PURPOSE_FETCH_ANYONE_HOSTS: return 1; case DIR_PURPOSE_SERVER: default: diff --git a/src/feature/dircommon/directory.h b/src/feature/dircommon/directory.h index 7d861682bb..87f6d94715 100644 --- a/src/feature/dircommon/directory.h +++ b/src/feature/dircommon/directory.h @@ -70,7 +70,10 @@ const dir_connection_t *CONST_TO_DIR_CONN(const connection_t *c); /** A connection to a directory server: set after a hidden service descriptor * is downloaded. */ #define DIR_PURPOSE_HAS_FETCHED_HSDESC 22 -#define DIR_PURPOSE_MAX_ 22 +/** A connection to a .anyone service: fetch the anyone_hosts DNS mapping + * file from one of the DNS service nodes. */ +#define DIR_PURPOSE_FETCH_ANYONE_HOSTS 23 +#define DIR_PURPOSE_MAX_ 23 /** True iff p is a purpose corresponding to uploading * data to a directory server. */ diff --git a/src/include.am b/src/include.am index 3d3141ecda..0e22f8fc75 100644 --- a/src/include.am +++ b/src/include.am @@ -61,6 +61,7 @@ include src/core/mainloop/include.am include src/core/or/include.am include src/core/proto/include.am +include src/feature/anyone/include.am include src/feature/api/include.am include src/feature/client/include.am include src/feature/control/include.am diff --git a/src/test/include.am b/src/test/include.am index ca52dbac03..73f38c2a97 100644 --- a/src/test/include.am +++ b/src/test/include.am @@ -128,6 +128,7 @@ src_test_test_SOURCES += \ src/test/test_addr.c \ src/test/test_address.c \ src/test/test_address_set.c \ + src/test/test_anyone_hosts_update.c \ src/test/test_bridges.c \ src/test/test_btrack.c \ src/test/test_buffers.c \ diff --git a/src/test/test.c b/src/test/test.c index 317b570d8e..d9ca4233ae 100644 --- a/src/test/test.c +++ b/src/test/test.c @@ -589,6 +589,7 @@ struct testgroup_t testgroups[] = { { "addr/", addr_tests }, { "address/", address_tests }, { "address_set/", address_set_tests }, + { "anyone_hosts_update/", anyone_hosts_update_tests }, { "bridges/", bridges_tests }, { "buffer/", buffer_tests }, { "bwmgt/", bwmgt_tests }, diff --git a/src/test/test.h b/src/test/test.h index 7a405b649e..b887fb3966 100644 --- a/src/test/test.h +++ b/src/test/test.h @@ -88,6 +88,7 @@ extern struct testcase_t accounting_tests[]; extern struct testcase_t addr_tests[]; extern struct testcase_t address_set_tests[]; extern struct testcase_t address_tests[]; +extern struct testcase_t anyone_hosts_update_tests[]; extern struct testcase_t bridges_tests[]; extern struct testcase_t btrack_tests[]; extern struct testcase_t buffer_tests[]; diff --git a/src/test/test_anyone_hosts_update.c b/src/test/test_anyone_hosts_update.c new file mode 100644 index 0000000000..68a3c5cbeb --- /dev/null +++ b/src/test/test_anyone_hosts_update.c @@ -0,0 +1,404 @@ +/* Copyright (c) 2007-2021, The Tor Project, Inc. */ +/* See LICENSE for licensing information */ + +/** + * \file test_anyone_hosts_update.c + * \brief Tests for the anyone_hosts auto-update scheduling/backoff logic in + * feature/anyone/anyone_hosts_update.c. + * + * These tests mock directory_initiate_request() so that no real network + * activity happens, and assert *when* and *which* fetches are launched: + * - the feature is disabled when AnyoneHostsUpdate is 0, + * - the periodic callback and the consensus hook are gated by the + * configured AnyoneHostsUpdateTrigger, + * - only one fetch runs at a time (overlapping fetches are prevented), + * - the configured update interval and the minimum retry interval are + * honoured, and + * - the round-robin URL selection advances across attempts (with the + * AnyoneHostsURL override taking precedence). + **/ + +#define DIRCLIENT_PRIVATE + +#include "core/or/or.h" +#include "feature/anyone/anyone_hosts_update.h" +#include "feature/dirclient/dirclient.h" +#include "feature/dircommon/directory.h" +#include "app/config/config.h" +#include "app/config/or_options_st.h" +#include "lib/encoding/confline.h" +#include "lib/fs/files.h" +#include "lib/malloc/malloc.h" + +#include "test/test.h" + +/* Mirror of the (file-private) timing constants in anyone_hosts_update.c so + * the assertions below line up with the implementation. */ +#define TEST_MIN_RETRY 3600 +#define TEST_FETCH_TIMEOUT 600 + +/* Fixed wall-clock base for the tests. */ +#define BASE_TIME ((time_t)1500000000) + +/* ---- capture of launched directory requests ---- */ + +static int n_fetches_launched = 0; +static uint8_t last_dir_purpose = 0; +static char *last_onion_address = NULL; +static char *last_resource = NULL; + +static void +mock_directory_initiate_request(directory_request_t *req) +{ + n_fetches_launched++; + last_dir_purpose = req->dir_purpose; + tor_free(last_onion_address); + last_onion_address = req->anon_onion_address ? + tor_strdup(req->anon_onion_address) : NULL; + tor_free(last_resource); + last_resource = req->resource ? tor_strdup(req->resource) : NULL; +} + +static void +reset_fetch_capture(void) +{ + n_fetches_launched = 0; + last_dir_purpose = 0; + tor_free(last_onion_address); + tor_free(last_resource); +} + +/** Configure the update-relevant options. With no AnyoneHostsURL override + * the fetch source list comes from the built-in DEFAULT_ANON_DNS_MAPPING. */ +static void +set_update_options(int enabled, const char *trigger, int interval) +{ + or_options_t *opt = get_options_mutable(); + opt->AnyoneHostsUpdate = enabled; + opt->AnyoneHostsUpdateInterval = interval; + tor_free(opt->AnyoneHostsUpdateTrigger); + opt->AnyoneHostsUpdateTrigger = trigger ? tor_strdup(trigger) : NULL; + tor_free(opt->AnyoneHostsFetchPath); + opt->AnyoneHostsFetchPath = tor_strdup("/anyone_hosts"); + config_free_lines(opt->AnyoneHostsURL); + opt->AnyoneHostsURL = NULL; + opt->DNSMappingFileMaxSize = 0; /* no cap */ +} + +/* ---- tests ---- */ + +/** With the feature disabled, neither trigger should launch a fetch. */ +static void +test_anyone_hosts_update_disabled(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(0 /* disabled */, "both", 7200); + + anyone_hosts_update_callback(BASE_TIME, get_options()); + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 0); + + done: + UNMOCK(directory_initiate_request); +} + +/** A periodic fetch is launched with the right purpose, resource, and a + * .anyone onion target, and the callback returns the configured interval. */ +static void +test_anyone_hosts_update_periodic_launch(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "periodic", 7200); + + int r = anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(r, OP_EQ, 7200); /* callback always reports the interval */ + tt_int_op(n_fetches_launched, OP_EQ, 1); + tt_int_op(last_dir_purpose, OP_EQ, DIR_PURPOSE_FETCH_ANYONE_HOSTS); + tt_assert(last_resource); + tt_str_op(last_resource, OP_EQ, "/anyone_hosts"); + + /* The target is routed by .anyone name. */ + tt_assert(last_onion_address); + size_t l = strlen(last_onion_address); + tt_assert(l >= 7); + tt_str_op(last_onion_address + l - 7, OP_EQ, ".anyone"); + + done: + UNMOCK(directory_initiate_request); +} + +/** The trigger setting routes which entry point may launch a fetch. */ +static void +test_anyone_hosts_update_trigger_routing(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + + /* "periodic": only the periodic callback launches. */ + reset_fetch_capture(); + anyone_hosts_update_init(); + set_update_options(1, "periodic", 7200); + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 0); + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* "consensus": only the consensus hook launches. */ + reset_fetch_capture(); + anyone_hosts_update_init(); + set_update_options(1, "consensus", 7200); + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 0); + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* "both": the periodic callback launches (fresh state). */ + reset_fetch_capture(); + anyone_hosts_update_init(); + set_update_options(1, "both", 7200); + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* "both": the consensus hook launches (fresh state). */ + reset_fetch_capture(); + anyone_hosts_update_init(); + set_update_options(1, "both", 7200); + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* An unrecognised trigger launches nothing. */ + reset_fetch_capture(); + anyone_hosts_update_init(); + set_update_options(1, "bogus", 7200); + anyone_hosts_update_callback(BASE_TIME, get_options()); + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 0); + + done: + UNMOCK(directory_initiate_request); +} + +/** While a fetch is in progress, further triggers must not start a second, + * overlapping fetch. */ +static void +test_anyone_hosts_update_no_overlap(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "both", 7200); + + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* Both entry points are no-ops while the first fetch is in flight. */ + anyone_hosts_update_callback(BASE_TIME, get_options()); + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* Once the fetch reports success the flag clears, but the success interval + * now blocks an immediate refetch. */ + anyone_hosts_update_note_result(1, BASE_TIME); + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + done: + UNMOCK(directory_initiate_request); +} + +/** After a success, the configured interval must elapse before the next + * fetch. */ +static void +test_anyone_hosts_update_interval_after_success(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "periodic", 7200); + + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + anyone_hosts_update_note_result(1, BASE_TIME); + + /* Before the interval elapses: blocked. */ + anyone_hosts_update_callback(BASE_TIME + 7199, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* At the interval boundary: a fresh fetch launches. */ + anyone_hosts_update_callback(BASE_TIME + 7200, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 2); + + done: + UNMOCK(directory_initiate_request); +} + +/** After a failure, the minimum retry interval prevents a retry storm. */ +static void +test_anyone_hosts_update_retry_backoff(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "periodic", 7200); + + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + anyone_hosts_update_note_result(0 /* failure */, BASE_TIME); + + /* Well within the minimum retry interval: no retry. */ + anyone_hosts_update_callback(BASE_TIME + 60, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + anyone_hosts_update_callback(BASE_TIME + (TEST_MIN_RETRY - 1), + get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* Once the minimum retry interval elapses, a retry is allowed. */ + anyone_hosts_update_callback(BASE_TIME + TEST_MIN_RETRY, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 2); + + done: + UNMOCK(directory_initiate_request); +} + +/** Consecutive attempts round-robin to different servers, and an + * AnyoneHostsURL override is tried first. */ +static void +test_anyone_hosts_update_url_selection(void *arg) +{ + (void)arg; + char *first = NULL; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "periodic", 7200); + + /* First attempt. */ + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + tt_assert(last_onion_address); + first = tor_strdup(last_onion_address); + + /* A failed attempt advances the round-robin index. */ + anyone_hosts_update_note_result(0, BASE_TIME); + anyone_hosts_update_callback(BASE_TIME + TEST_MIN_RETRY, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 2); + tt_assert(last_onion_address); + /* The second attempt targets a different server. */ + tt_str_op(last_onion_address, OP_NE, first); + + /* With an explicit AnyoneHostsURL override, that address is tried first. */ + reset_fetch_capture(); + anyone_hosts_update_init(); + set_update_options(1, "periodic", 7200); + config_line_append(&get_options_mutable()->AnyoneHostsURL, + "AnyoneHostsURL", "override1.anyone"); + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + tt_assert(last_onion_address); + tt_str_op(last_onion_address, OP_EQ, "override1.anyone"); + + done: + tor_free(first); + UNMOCK(directory_initiate_request); +} + +/** A fetch that never reports a result is eventually treated as failed so it + * does not block updates forever. */ +static void +test_anyone_hosts_update_stuck_timeout(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "periodic", 7200); + + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* The in-progress flag blocks new fetches before the stuck timeout. */ + anyone_hosts_update_callback(BASE_TIME + 1, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* At the timeout the stuck fetch is cleared, but no new fetch is launched + * in the same call. */ + anyone_hosts_update_callback(BASE_TIME + TEST_FETCH_TIMEOUT, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + + /* After the minimum retry interval, a fresh fetch can proceed. */ + anyone_hosts_update_callback(BASE_TIME + TEST_MIN_RETRY, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 2); + + done: + UNMOCK(directory_initiate_request); +} + +/** Addresses are pulled out of the saved anyone_hosts mapping file even when + * the name and address are separated by a tab rather than a space. */ +static void +test_anyone_hosts_update_file_tab_separator(void *arg) +{ + (void)arg; + char *fname = NULL; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, "periodic", 7200); + + /* Write a saved mapping whose single entry uses a TAB separator. With no + * AnyoneHostsURL override this address is first in the fallback list, so it + * is the target of the next fetch -- but only if tab separators are + * recognised. */ + fname = get_datadir_fname("anyone_hosts"); + static const char tab_mapping[] = + "myhost.anyone\tuniquetabaddr0123456789.anyone\n"; + tt_int_op(0, OP_EQ, write_str_to_file(fname, tab_mapping, 0)); + + anyone_hosts_update_callback(BASE_TIME, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 1); + tt_assert(last_onion_address); + tt_str_op(last_onion_address, OP_EQ, "uniquetabaddr0123456789.anyone"); + + done: + if (fname) + tor_unlink(fname); + tor_free(fname); + UNMOCK(directory_initiate_request); +} + +struct testcase_t anyone_hosts_update_tests[] = { + { "disabled", test_anyone_hosts_update_disabled, TT_FORK, NULL, NULL }, + { "periodic_launch", test_anyone_hosts_update_periodic_launch, TT_FORK, + NULL, NULL }, + { "trigger_routing", test_anyone_hosts_update_trigger_routing, TT_FORK, + NULL, NULL }, + { "no_overlap", test_anyone_hosts_update_no_overlap, TT_FORK, NULL, NULL }, + { "interval_after_success", test_anyone_hosts_update_interval_after_success, + TT_FORK, NULL, NULL }, + { "retry_backoff", test_anyone_hosts_update_retry_backoff, TT_FORK, + NULL, NULL }, + { "url_selection", test_anyone_hosts_update_url_selection, TT_FORK, + NULL, NULL }, + { "stuck_timeout", test_anyone_hosts_update_stuck_timeout, TT_FORK, + NULL, NULL }, + { "file_tab_separator", test_anyone_hosts_update_file_tab_separator, + TT_FORK, NULL, NULL }, + END_OF_TESTCASES +};