diff --git a/changes/anyone-hosts-autoupdate b/changes/anyone-hosts-autoupdate new file mode 100644 index 0000000000..39d3bd1ce2 --- /dev/null +++ b/changes/anyone-hosts-autoupdate @@ -0,0 +1,16 @@ + 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 on a periodic schedule. Fetches + are routed by .anyone name over hidden-service circuits. A fetched + file is installed only if it carries a valid signature from a + trusted DNS signer, is unexpired, contains at least one mapping, and + is not older than the installed mapping. New configuration options + AnyoneHostsUpdate, AnyoneHostsUpdateInterval, and AnyoneHostsURL + 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..2fd2218d55 100644 --- a/doc/man/anon.1.txt +++ b/doc/man/anon.1.txt @@ -1222,10 +1222,32 @@ 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 on a periodic schedule. + Fetched files are installed only if they carry a valid signature from + a trusted DNS signer, have not expired, and are not older than the + currently installed mapping. (Default: 1) + +[[AnyoneHostsUpdateInterval]] **AnyoneHostsUpdateInterval** __N__ **seconds**|**minutes**|**hours**|**days**|**weeks**:: + How often to attempt fetching a fresh `anyone_hosts` file. The actual + schedule is randomly jittered by up to +/-10% around this value. + The minimum is 1 hour. (Default: 12 hours) + +[[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. A failing + address is skipped on the next attempt; a working address keeps being + used. (Default: none) [[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..b00317ffff 100644 --- a/src/app/config/config.c +++ b/src/app/config/config.c @@ -327,6 +327,9 @@ static const config_var_t option_vars_[] = { V(AlternateBridgeAuthority, LINELIST, NULL), V(AlternateDirAuthority, LINELIST, NULL), OBSOLETE("AlternateHSAuthority"), + V(AnyoneHostsUpdate, BOOL, "1"), + V(AnyoneHostsUpdateInterval, INTERVAL, "12 hours"), + V(AnyoneHostsURL, LINELIST, NULL), V(AssumeReachable, BOOL, "0"), V(AssumeReachableIPv6, AUTOBOOL, "auto"), OBSOLETE("AuthDirBadDir"), @@ -4062,6 +4065,23 @@ options_validate_cb(const void *old_options_, void *options_, char **msg) return -1; } + 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->AnyoneHostsUpdateInterval < 3600) { + REJECT("AnyoneHostsUpdateInterval must be at least 1 hour."); + } + return 0; } diff --git a/src/app/config/or_options_st.h b/src/app/config/or_options_st.h index 72ea13a9dd..72e81b3c46 100644 --- a/src/app/config/or_options_st.h +++ b/src/app/config/or_options_st.h @@ -705,6 +705,14 @@ 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; + /** How often (in seconds) to check for a new anyone_hosts file. */ + int AnyoneHostsUpdateInterval; + /** List of .anyone service addresses to try when fetching the + * anyone_hosts file. Tried in order before the built-in defaults. */ + struct config_line_t *AnyoneHostsURL; /** 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..9e8fd3e4c9 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,17 @@ 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, jittered). Whether an update may actually + * run (enabled, client-only, timing) is decided inside the anyone module. + */ +static int +update_anyone_hosts_callback(time_t now, const or_options_t *options) +{ + 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 01cbfd8938..fc4ffbc082 100644 --- a/src/core/or/connection_edge.c +++ b/src/core/or/connection_edge.c @@ -3640,6 +3640,43 @@ connection_ap_make_link(connection_t *partner, return conn; } +/** Like connection_ap_make_link(), but for a target that is an onion-routed + * hostname (a .anyone name) rather than a numeric address. + * + * connection_ap_make_link() queues the stream for direct circuit attachment, + * which treats the address as an exit-resolvable hostname; a .anyone name + * would reach the exit unresolved and fail. Instead, send the linked stream + * through connection_ap_handshake_rewrite_and_attach(), the same path SOCKS + * client streams take, so the name is translated via the anyone_hosts + * mapping and attached to a hidden-service circuit. + * + * Return the new entry connection, or NULL on error. On error the stream + * has already been marked for close, so the partner connection will see EOF. + */ +entry_connection_t * +connection_ap_make_link_onion(connection_t *partner, + char *address, uint16_t port, + int session_group, int isolation_flags) +{ + entry_connection_t *conn = + connection_ap_make_link(partner, address, port, NULL /* digest */, + session_group, isolation_flags, + 0 /* use_begindir */, 0 /* want_onehop */); + if (!conn) + return NULL; + + /* Undo the direct-attach queueing and take the rewrite path instead. */ + connection_ap_mark_as_non_pending_circuit(conn); + /* The rewrite path refuses onion targets unless the stream's entry + * configuration allows them; internal fetches always may. */ + conn->entry_cfg.onion_traffic = 1; + if (connection_ap_handshake_rewrite_and_attach(conn, NULL, NULL) < 0) { + /* The connection has already been marked unattached and closed. */ + return NULL; + } + return conn; +} + /** Notify any interested controller connections about a new hostname resolve * or resolve error. Takes the same arguments as does * connection_ap_handshake_socks_resolved(). */ diff --git a/src/core/or/connection_edge.h b/src/core/or/connection_edge.h index 3c6e4f02f0..e6665927bd 100644 --- a/src/core/or/connection_edge.h +++ b/src/core/or/connection_edge.h @@ -114,6 +114,11 @@ entry_connection_t *connection_ap_make_link(connection_t *partner, int session_group, int isolation_flags, int use_begindir, int want_onehop); +entry_connection_t *connection_ap_make_link_onion(connection_t *partner, + char *address, + uint16_t port, + int session_group, + int isolation_flags); void connection_ap_handshake_socks_reply(entry_connection_t *conn, char *reply, size_t replylen, int endreason); diff --git a/src/feature/anyone/anyone_hosts_update.c b/src/feature/anyone/anyone_hosts_update.c new file mode 100644 index 0000000000..51784eccd5 --- /dev/null +++ b/src/feature/anyone/anyone_hosts_update.c @@ -0,0 +1,404 @@ +/* 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 target from the configured AnyoneHostsURL list + * (falling back to the DNS service addresses in DEFAULT_ANON_DNS_MAPPING) + * and issues an anonymised HTTP GET for ANYONE_HOSTS_FETCH_PATH. The + * request is routed to the target by .anyone name, over a hidden-service + * circuit, through the same rewrite path client SOCKS streams use. + * + * A fetched file is installed only when all of the following hold: + * - it is non-empty, within DNSMappingFileMaxSize, and carries a valid + * signature from a trusted DNS signer (there is deliberately no + * configuration to weaken the signature requirement), + * - its valid-until time, when present, has not passed, and + * - it is not older (by published time) than the installed mapping. + * + * A fetch is only launched when AnyoneHostsUpdate is enabled, no fetch is + * already in progress, at least AnyoneHostsUpdateInterval seconds have + * elapsed since the last accepted file, and at least + * ANYONE_HOSTS_MIN_RETRY_INTERVAL seconds have elapsed since a failed + * attempt. + **/ + +#include "core/or/or.h" +#include "feature/anyone/anyone_hosts_update.h" +#include "feature/dircommon/directory.h" +#include "feature/dirclient/dirclient.h" +#include "feature/dirparse/anyone_hosts_parse.h" +#include "feature/relay/routermode.h" +#include "app/config/config.h" +#include "lib/crypt_ops/crypto_rand.h" +#include "lib/encoding/confline.h" +#include "lib/fs/files.h" +#include "lib/log/escape.h" + +#ifdef HAVE_UNISTD_H +#include +#endif +#ifdef HAVE_FCNTL_H +#include +#endif + +/** Minimum gap between fetch attempts after a failure (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 *accepted* fetch. */ +static time_t last_success_time = 0; + +/** Index into the target list; advanced only when an attempt fails, so a + * working server keeps serving future updates. */ +static int current_url_index = 0; + +/** ---------- helpers ---------- */ + +/** + * Scan text (mapping-file format) and, for every mapping line + * " " whose address ends in ".anyone", append a copy + * of the address to out (if non-NULL). Metadata lines of the signed + * format (anyone-hosts-*, published, valid-until, PEM markers) are skipped. + * Return the number of addresses found. + */ +STATIC int +anyone_hosts_extract_addresses(const char *text, smartlist_t *out) +{ + int n_found = 0; + 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-") || + !strcmpstart(line, "published") || + !strcmpstart(line, "valid-until") || + !strcmpstart(line, "-----")) + continue; + const char *addr = eat_whitespace_no_nl(find_whitespace(line)); + if (*addr && !strcmpend(addr, ".anyone")) { + if (out) + smartlist_add(out, tor_strdup(addr)); + ++n_found; + } + } SMARTLIST_FOREACH_END(line); + + SMARTLIST_FOREACH(lines, char *, s, tor_free(s)); + smartlist_free(lines); + tor_free(copy); + return n_found; +} + +/** + * Build an ordered smartlist of onion-address strings to try for fetching + * the anyone_hosts file: AnyoneHostsURL config entries (user overrides) + * first, then the addresses from DEFAULT_ANON_DNS_MAPPING. Caller must + * free each element and the list. + */ +static smartlist_t * +anyone_hosts_get_url_list(void) +{ + smartlist_t *urls = smartlist_new(); + const or_options_t *options = get_options(); + + 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)); + } + + anyone_hosts_extract_addresses(DEFAULT_ANON_DNS_MAPPING, urls); + + return urls; +} + +/** Return true iff auto-updating is enabled for this configuration. + * Relays can also have the CLIENT role (e.g. when ControlPort is set), but + * anyone_hosts auto-update is intended for actual clients only. */ +static int +update_is_enabled(const or_options_t *options) +{ + return options->AnyoneHostsUpdate && !server_mode(options); +} + +/** Read the currently installed anyone_hosts file into a newly allocated + * string, or return NULL if it is missing or unreadable. Applies the + * DNSMappingFileMaxSize read cap, treating 0 as "no limit". */ +static char * +read_installed_hosts_file(size_t *sz_out) +{ + const or_options_t *options = get_options(); + char *fname = get_datadir_fname("anyone_hosts"); + int fd = tor_open_cloexec(fname, O_RDONLY, 0); + tor_free(fname); + if (fd < 0) + return NULL; + 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. */ + 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; + char *content = read_file_to_str_until_eof(fd, max_size, sz_out); + close(fd); + return content; +} + +/** ---------- public API ---------- */ + +void +anyone_hosts_update_init(void) +{ + fetch_in_progress = 0; + last_attempt_time = 0; + last_success_time = 0; + current_url_index = 0; +} + +/** Called when a DIR_PURPOSE_FETCH_ANYONE_HOSTS fetch completes + * (successfully or not). Clears the in-progress flag; on success records + * the time so the interval timer resets, on failure advances the target + * index so the next attempt tries a different server. */ +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. */ + if (!fetch_in_progress) + return; + fetch_in_progress = 0; + if (success) + last_success_time = now; + else + 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 (!update_is_enabled(options)) + return; + if (fetch_in_progress) { + /* Safety net: directory_initiate_request() returns void and can fail + * before any connection exists, in which case nothing ever calls + * anyone_hosts_update_note_result(). Don't stay stuck forever. + * (Don't fall through to launch a new fetch here: a live connection + * may still call note_result() later.) */ + 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 after an accepted fetch. */ + 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. */ + if (last_attempt_time && last_attempt_time > last_success_time && + (now - last_attempt_time) < ANYONE_HOSTS_MIN_RETRY_INTERVAL) + return; + + smartlist_t *urls = anyone_hosts_get_url_list(); + if (smartlist_len(urls) == 0) { + log_info(LD_DIR, "anyone_hosts update: no target addresses available."); + 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 routed by .anyone name over a hidden-service circuit; the DNS + * service serves the file over HTTP on port 80 behind its onion + * service. */ + directory_request_t *req = + directory_request_new(DIR_PURPOSE_FETCH_ANYONE_HOSTS); + directory_request_set_indirection(req, DIRIND_ANONYMOUS); + + 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); + directory_request_set_resource(req, ANYONE_HOSTS_FETCH_PATH); + + /* A directory request requires an identity digest; it is unused for a + * name-routed onion 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) +{ + maybe_launch_fetch(now); +} + +int +anyone_hosts_update_callback(time_t now, const or_options_t *options) +{ + int interval = options->AnyoneHostsUpdateInterval; + + maybe_launch_fetch(now); + + /* Jitter the next run by up to +/-10% so identically configured clients + * do not fetch in lockstep. */ + int jitter = interval / 10; + if (jitter > 0) + interval += crypto_rand_int((unsigned)(2 * jitter + 1)) - jitter; + return interval; +} + +/** Validate the body of a fetch response and atomically install it as the + * new anyone_hosts file if acceptable. peer_desc describes the + * server for log messages. Returns 0 if the file was accepted (including + * the case where it was identical to the installed file), -1 otherwise. */ +int +anyone_hosts_handle_fetch_response(const char *peer_desc, int status_code, + const char *reason, + const char *body, size_t body_len, + time_t now) +{ + const or_options_t *options = get_options(); + 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), peer_desc); + goto done; + } + + if (!body || body_len == 0) { + log_warn(LD_DIR, "anyone_hosts file from %s is empty; discarding.", + peer_desc); + goto done; + } + + 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.", + peer_desc, body_len, options->DNSMappingFileMaxSize); + goto done; + } + + /* The fetched file must carry a valid signature from a trusted DNS + * signer. There is deliberately no configuration to weaken this: an + * unsigned or badly signed mapping would let whichever node served the + * fetch rewrite every .anyone name. */ + time_t published = 0, valid_until = 0; + anyone_hosts_sig_status_t sig = + anyone_hosts_parse_and_verify_ex(body, body_len, + &published, &valid_until); + if (sig != ANYONE_HOSTS_SIG_VALID) { + log_warn(LD_DIR, "anyone_hosts file from %s: signature check failed " + "(status %d); discarding.", peer_desc, (int)sig); + goto done; + } + + if (valid_until && valid_until < now) { + log_warn(LD_DIR, "anyone_hosts file from %s expired %ld seconds ago; " + "discarding.", peer_desc, (long)(now - valid_until)); + goto done; + } + + /* Require at least one mapping so a signed-but-empty document cannot + * wipe the installed file. */ + if (anyone_hosts_extract_addresses(body, NULL) == 0) { + log_warn(LD_DIR, "anyone_hosts file from %s contains no mappings; " + "discarding.", peer_desc); + goto done; + } + + /* Compare against the installed file: skip byte-identical rewrites, and + * refuse to replace a newer installed mapping with an older one, so a + * replayed (validly signed but stale) file cannot roll the mapping + * back. */ + { + size_t cur_len = 0; + char *cur = read_installed_hosts_file(&cur_len); + if (cur) { + if (cur_len == body_len && memcmp(cur, body, body_len) == 0) { + log_info(LD_DIR, "anyone_hosts file from %s is unchanged.", + peer_desc); + tor_free(cur); + success = 1; + goto done; + } + time_t cur_published = 0; + (void) anyone_hosts_parse_and_verify_ex(cur, cur_len, + &cur_published, NULL); + tor_free(cur); + if (cur_published && published && published < cur_published) { + log_warn(LD_DIR, "anyone_hosts file from %s is older than the " + "installed mapping (published %ld < %ld); discarding.", + peer_desc, (long)published, (long)cur_published); + goto done; + } + } + } + + /* 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); + tor_free(hosts_fname); + if (!write_ok) { + log_warn(LD_FS, "Error writing anyone_hosts file."); + goto done; + } + } + + log_info(LD_DIR, "Successfully updated anyone_hosts file (%"TOR_PRIuSZ + " bytes).", body_len); + success = 1; + + done: + anyone_hosts_update_note_result(success, now); + return success ? 0 : -1; +} diff --git a/src/feature/anyone/anyone_hosts_update.h b/src/feature/anyone/anyone_hosts_update.h new file mode 100644 index 0000000000..f178a7ae52 --- /dev/null +++ b/src/feature/anyone/anyone_hosts_update.h @@ -0,0 +1,54 @@ +/* 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" +#include +#include + +struct or_options_t; +struct smartlist_t; + +/** HTTP resource path requested from the DNS service nodes. */ +#define ANYONE_HOSTS_FETCH_PATH "/tld/anyone" + +/** Initialize (or reset) the anyone_hosts update subsystem state. */ +void anyone_hosts_update_init(void); + +/** Called after a consensus is successfully loaded; may kick off a fetch + * if configuration and timing allow it. */ +void anyone_hosts_update_maybe_kick(time_t now); + +/** Called when a DIR_PURPOSE_FETCH_ANYONE_HOSTS fetch completes. + * success is 1 if a file was accepted, 0 otherwise. */ +void anyone_hosts_update_note_result(int success, time_t now); + +/** Periodic-event callback: try to fetch a fresh anyone_hosts file. + * Returns the number of seconds until the next run. */ +int anyone_hosts_update_callback(time_t now, + const struct or_options_t *options); + +/** Validate and install the body of a fetch response. Returns 0 if the + * file was accepted, -1 otherwise. Reports the outcome to the scheduler + * via anyone_hosts_update_note_result(). */ +int anyone_hosts_handle_fetch_response(const char *peer_desc, + int status_code, const char *reason, + const char *body, size_t body_len, + time_t now); + +#ifdef ANYONE_HOSTS_UPDATE_PRIVATE +STATIC int anyone_hosts_extract_addresses(const char *text, + struct smartlist_t *out); +#endif + +#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..69be73e980 100644 --- a/src/feature/dirclient/dirclient.c +++ b/src/feature/dirclient/dirclient.c @@ -47,9 +47,9 @@ #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/rend/rendcommon.h" #include "feature/stats/predict_ports.h" - #include "lib/cc/ctassert.h" #include "lib/compress/compress.h" #include "lib/crypt_ops/crypto_format.h" @@ -122,6 +122,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 +162,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 +773,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, approx_time()); } } @@ -1048,6 +1060,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 +1292,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 +1332,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 +1349,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 +1377,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; @@ -1420,12 +1456,22 @@ directory_initiate_request,(directory_request_t *request)) * populate it and add it at the right state * hook up both sides */ - linked_conn = - connection_ap_make_link(TO_CONN(conn), - conn->base_.address, conn->base_.port, - digest, - SESSION_GROUP_DIRCONN, iso_flags, - use_begindir, !anonymized_connection); + if (anon_onion_address) { + /* The target is a .anyone name, not a relay: send the linked stream + * through the rewrite path so the name is resolved via the + * anyone_hosts mapping and attached to a hidden-service circuit. */ + linked_conn = + connection_ap_make_link_onion(TO_CONN(conn), + conn->base_.address, conn->base_.port, + SESSION_GROUP_DIRCONN, iso_flags); + } else { + linked_conn = + connection_ap_make_link(TO_CONN(conn), + conn->base_.address, conn->base_.port, + digest, + SESSION_GROUP_DIRCONN, iso_flags, + use_begindir, !anonymized_connection); + } if (!linked_conn) { log_warn(LD_NET,"Making tunnel to dirserver failed."); connection_mark_for_close(TO_CONN(conn)); @@ -1705,6 +1751,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 +1896,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 +2025,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 +2037,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 +2258,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 +2382,7 @@ 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); + anyone_hosts_update_maybe_kick(now); if (authdir_mode_v3(get_options())) { sr_act_post_consensus( @@ -2842,6 +2900,23 @@ 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. + * Validation and installation policy live in the anyone module. + **/ +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); + return anyone_hosts_handle_fetch_response( + connection_describe_peer(TO_CONN(conn)), + args->status_code, args->reason, + args->body, args->body_len, + approx_time()); +} + /** 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/feature/dirparse/anyone_hosts_parse.c b/src/feature/dirparse/anyone_hosts_parse.c index 6b2d6a313c..31730d1243 100644 --- a/src/feature/dirparse/anyone_hosts_parse.c +++ b/src/feature/dirparse/anyone_hosts_parse.c @@ -92,6 +92,11 @@ is_trusted_dns_signer(const char *signer_address) * * body is the full file content, body_len is its length. * + * If published_out / valid_until_out are non-NULL, they are + * set to the document's "published" / "valid-until" times whenever those + * tokens parse, regardless of the final signature status; absent or + * unparseable times leave them at 0. + * * Returns: * ANYONE_HOSTS_SIG_VALID — signature verified successfully * ANYONE_HOSTS_SIG_INVALID — signature present but verification failed @@ -99,8 +104,10 @@ is_trusted_dns_signer(const char *signer_address) * ANYONE_HOSTS_SIG_BAD_SIGNER — signer is not a trusted DNS service * ANYONE_HOSTS_SIG_PARSE_ERROR — file has signature headers but is malformed */ -anyone_hosts_sig_status_t -anyone_hosts_parse_and_verify(const char *body, size_t body_len) +MOCK_IMPL(anyone_hosts_sig_status_t, +anyone_hosts_parse_and_verify_ex,(const char *body, size_t body_len, + time_t *published_out, + time_t *valid_until_out)) { smartlist_t *tokens = NULL; memarea_t *area = NULL; @@ -110,6 +117,11 @@ anyone_hosts_parse_and_verify(const char *body, size_t body_len) tor_assert(body); + if (published_out) + *published_out = 0; + if (valid_until_out) + *valid_until_out = 0; + /* Quick check: if the file doesn't start with our version keyword, * it's an unsigned plain-text file (backward compatible). */ if (body_len < strlen("anyone-hosts-version") || @@ -135,6 +147,20 @@ anyone_hosts_parse_and_verify(const char *body, size_t body_len) goto done; } + /* Extract the published / valid-until times when requested. These are + * reported for any document that tokenizes, so callers can consult them + * even when the signature is absent or bad. */ + if (published_out) { + tok = find_opt_by_keyword(tokens, K_PUBLISHED); + if (tok && tok->n_args >= 1) + (void) parse_iso_time(tok->args[0], published_out); + } + if (valid_until_out) { + tok = find_opt_by_keyword(tokens, K_VALID_UNTIL); + if (tok && tok->n_args >= 1) + (void) parse_iso_time(tok->args[0], valid_until_out); + } + /* Find the signature token. */ tok = find_opt_by_keyword(tokens, K_ANYONE_HOSTS_SIGNATURE); if (!tok) { @@ -257,3 +283,11 @@ anyone_hosts_parse_and_verify(const char *body, size_t body_len) memarea_drop_all(area); return result; } + +/** Backward-compatible wrapper for anyone_hosts_parse_and_verify_ex() + * that discards the published / valid-until times. */ +anyone_hosts_sig_status_t +anyone_hosts_parse_and_verify(const char *body, size_t body_len) +{ + return anyone_hosts_parse_and_verify_ex(body, body_len, NULL, NULL); +} diff --git a/src/feature/dirparse/anyone_hosts_parse.h b/src/feature/dirparse/anyone_hosts_parse.h index aceb2b6003..794b1e52ed 100644 --- a/src/feature/dirparse/anyone_hosts_parse.h +++ b/src/feature/dirparse/anyone_hosts_parse.h @@ -21,4 +21,11 @@ typedef enum { anyone_hosts_sig_status_t anyone_hosts_parse_and_verify(const char *body, size_t body_len); +#include "lib/testsupport/testsupport.h" +#include + +MOCK_DECL(anyone_hosts_sig_status_t, anyone_hosts_parse_and_verify_ex, + (const char *body, size_t body_len, + time_t *published_out, time_t *valid_until_out)); + #endif /* !defined(TOR_ANYONE_HOSTS_PARSE_H) */ 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..774fe666af --- /dev/null +++ b/src/test/test_anyone_hosts_update.c @@ -0,0 +1,567 @@ +/* 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 logic in + * feature/anyone/anyone_hosts_update.c. + * + * The scheduling tests mock directory_initiate_request() so that no real + * network activity happens, and assert *when* and *which* fetches are + * launched. The response tests mock anyone_hosts_parse_and_verify_ex() + * (driven by markers embedded in the test bodies) and assert which fetched + * documents are installed. + **/ + +#define DIRCLIENT_PRIVATE +#define ANYONE_HOSTS_UPDATE_PRIVATE + +#include "core/or/or.h" +#include "feature/anyone/anyone_hosts_update.h" +#include "feature/dirclient/dirclient.h" +#include "feature/dircommon/directory.h" +#include "feature/dirparse/anyone_hosts_parse.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/fs/dir.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 target list comes from the built-in DEFAULT_ANON_DNS_MAPPING. */ +static void +set_update_options(int enabled, int interval) +{ + or_options_t *opt = get_options_mutable(); + opt->AnyoneHostsUpdate = enabled; + opt->AnyoneHostsUpdateInterval = interval; + config_free_lines(opt->AnyoneHostsURL); + opt->AnyoneHostsURL = NULL; + opt->DNSMappingFileMaxSize = 0; /* no cap */ +} + +/** Assert that a jittered periodic-callback return value r is within + * +/-10% of interval. */ +#define ASSERT_JITTERED_INTERVAL(r, interval) \ + do { \ + tt_int_op((r), OP_GE, (interval) - (interval) / 10); \ + tt_int_op((r), OP_LE, (interval) + (interval) / 10); \ + } while (0) + +/* ---- mock signature verification for the response tests ---- + * + * The mock derives its result from markers in the body: + * "SIG=VALID" / "SIG=UNSIGNED" / "SIG=INVALID" -> returned status + * "PUB=" -> *published_out = n + * "VU=" -> *valid_until_out = n + */ +static anyone_hosts_sig_status_t +mock_parse_and_verify_ex(const char *body, size_t body_len, + time_t *published_out, time_t *valid_until_out) +{ + (void)body_len; + const char *m; + if (published_out) { + *published_out = 0; + if ((m = strstr(body, "PUB="))) + *published_out = (time_t)atoi(m + 4); + } + if (valid_until_out) { + *valid_until_out = 0; + if ((m = strstr(body, "VU="))) + *valid_until_out = (time_t)atoi(m + 3); + } + if (strstr(body, "SIG=VALID")) + return ANYONE_HOSTS_SIG_VALID; + if (strstr(body, "SIG=INVALID")) + return ANYONE_HOSTS_SIG_INVALID; + return ANYONE_HOSTS_SIG_UNSIGNED; +} + +/* ---- scheduling 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 */, 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); +} + +/** Servers (even ones with the CLIENT role) must not auto-update. */ +static void +test_anyone_hosts_update_server_mode(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, 7200); + get_options_mutable()->ORPort_set = 1; + + 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: + get_options_mutable()->ORPort_set = 0; + UNMOCK(directory_initiate_request); +} + +/** A periodic fetch is launched with the right purpose, resource, and a + * .anyone onion target, and the callback returns a jittered 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, 7200); + + int r = anyone_hosts_update_callback(BASE_TIME, get_options()); + ASSERT_JITTERED_INTERVAL(r, 7200); + 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_FETCH_PATH); + + /* 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 consensus hook may launch a fetch too. */ +static void +test_anyone_hosts_update_consensus_kick(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, 7200); + + anyone_hosts_update_maybe_kick(BASE_TIME); + tt_int_op(n_fetches_launched, OP_EQ, 1); + tt_int_op(last_dir_purpose, OP_EQ, DIR_PURPOSE_FETCH_ANYONE_HOSTS); + + 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, 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, 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, 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); +} + +/** An AnyoneHostsURL override is tried first; a failure advances to the + * next target, and a success keeps the working target. */ +static void +test_anyone_hosts_update_url_selection(void *arg) +{ + (void)arg; + MOCK(directory_initiate_request, mock_directory_initiate_request); + reset_fetch_capture(); + anyone_hosts_update_init(); + + set_update_options(1, 7200); + config_line_append(&get_options_mutable()->AnyoneHostsURL, + "AnyoneHostsURL", "override1.anyone"); + + /* The override is tried first. */ + 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"); + + /* A success does NOT advance the target: the next fetch (after the + * interval) goes to the same working server. */ + anyone_hosts_update_note_result(1, BASE_TIME); + anyone_hosts_update_callback(BASE_TIME + 7200, get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 2); + tt_str_op(last_onion_address, OP_EQ, "override1.anyone"); + + /* A failure advances to the next target in the list. */ + anyone_hosts_update_note_result(0, BASE_TIME + 7200); + anyone_hosts_update_callback(BASE_TIME + 7200 + TEST_MIN_RETRY, + get_options()); + tt_int_op(n_fetches_launched, OP_EQ, 3); + tt_assert(last_onion_address); + tt_str_op(last_onion_address, OP_NE, "override1.anyone"); + + done: + 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, 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); +} + +/* ---- helper tests ---- */ + +/** anyone_hosts_extract_addresses() finds mapping targets, tolerates tab + * separators, and skips the signed-format metadata lines. */ +static void +test_anyone_hosts_update_extract_addresses(void *arg) +{ + (void)arg; + smartlist_t *out = smartlist_new(); + + static const char doc[] = + "anyone-hosts-version 1\n" + "anyone-hosts-status signed\n" + "published 2026-01-01 00:00:00\n" + "valid-until 2026-02-01 00:00:00\n" + "one.anyone.anyone aaaa.anyone\n" + "two.anyone.anyone\tbbbb.anyone\n" + "bad-line-no-address\n" + "bad.anyone.anyone not-an-anyone-address\n" + "anyone-hosts-digest sha256 abc\n" + "anyone-hosts-signature cccc.anyone\n" + "-----BEGIN SIGNATURE-----\n" + "AAAA\n" + "-----END SIGNATURE-----\n"; + + int n = anyone_hosts_extract_addresses(doc, out); + tt_int_op(n, OP_EQ, 2); + tt_int_op(smartlist_len(out), OP_EQ, 2); + tt_str_op(smartlist_get(out, 0), OP_EQ, "aaaa.anyone"); + tt_str_op(smartlist_get(out, 1), OP_EQ, "bbbb.anyone"); + + /* Counting mode (out == NULL) returns the same count. */ + tt_int_op(anyone_hosts_extract_addresses(doc, NULL), OP_EQ, 2); + + done: + SMARTLIST_FOREACH(out, char *, s, tor_free(s)); + smartlist_free(out); +} + +/* ---- response validation tests ---- */ + +/* Bodies for the response tests. Each contains one real mapping line so + * the has-mappings check passes, plus markers for the mocked verifier. */ +#define GOOD_BODY(pub) \ + "anyone-hosts-version 1 SIG=VALID PUB=" #pub " VU=1600000000\n" \ + "map.anyone.anyone dddd.anyone\n" + +static void +test_anyone_hosts_update_response_validation(void *arg) +{ + (void)arg; + char *fname = get_datadir_fname("anyone_hosts"); + char *content = NULL; + MOCK(anyone_hosts_parse_and_verify_ex, mock_parse_and_verify_ex); + anyone_hosts_update_init(); + set_update_options(1, 7200); + tor_unlink(fname); + + /* now == BASE_TIME (1500000000) < VU (1600000000): not expired. */ + + /* Non-200 responses are rejected. */ + tt_int_op(anyone_hosts_handle_fetch_response("peer", 503, "busy", + "x", 1, BASE_TIME), + OP_EQ, -1); + + /* Empty bodies are rejected. */ + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + "", 0, BASE_TIME), + OP_EQ, -1); + + /* Unsigned bodies are rejected: the signature requirement cannot be + * configured away. */ + static const char unsigned_body[] = + "SIG=UNSIGNED\nmap.anyone.anyone dddd.anyone\n"; + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + unsigned_body, + strlen(unsigned_body), + BASE_TIME), + OP_EQ, -1); + tt_int_op(file_status(fname), OP_NE, FN_FILE); + + /* Expired documents are rejected. */ + static const char expired_body[] = + "SIG=VALID VU=1400000000\nmap.anyone.anyone dddd.anyone\n"; + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + expired_body, + strlen(expired_body), + BASE_TIME), + OP_EQ, -1); + + /* Valid documents with no mapping lines are rejected. */ + static const char no_mappings[] = "SIG=VALID PUB=1500000000\n"; + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + no_mappings, + strlen(no_mappings), + BASE_TIME), + OP_EQ, -1); + tt_int_op(file_status(fname), OP_NE, FN_FILE); + + /* A valid, in-date document with mappings is installed. */ + static const char good2[] = GOOD_BODY(1500000200); + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + good2, strlen(good2), + BASE_TIME), + OP_EQ, 0); + content = read_file_to_str(fname, 0, NULL); + tt_assert(content); + tt_str_op(content, OP_EQ, good2); + tor_free(content); + + /* A byte-identical refetch is accepted without rewriting. */ + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + good2, strlen(good2), + BASE_TIME), + OP_EQ, 0); + + /* A validly signed but OLDER document (replay) is rejected and the + * installed file is untouched. */ + static const char older[] = GOOD_BODY(1500000100); + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + older, strlen(older), + BASE_TIME), + OP_EQ, -1); + content = read_file_to_str(fname, 0, NULL); + tt_assert(content); + tt_str_op(content, OP_EQ, good2); + tor_free(content); + + /* A NEWER document replaces the installed one. */ + static const char newer[] = GOOD_BODY(1500000300); + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + newer, strlen(newer), + BASE_TIME), + OP_EQ, 0); + content = read_file_to_str(fname, 0, NULL); + tt_assert(content); + tt_str_op(content, OP_EQ, newer); + + done: + tor_free(content); + if (fname) + tor_unlink(fname); + tor_free(fname); + UNMOCK(anyone_hosts_parse_and_verify_ex); +} + +/** The size cap applies to fetched bodies. */ +static void +test_anyone_hosts_update_response_size_cap(void *arg) +{ + (void)arg; + char *fname = get_datadir_fname("anyone_hosts"); + MOCK(anyone_hosts_parse_and_verify_ex, mock_parse_and_verify_ex); + anyone_hosts_update_init(); + set_update_options(1, 7200); + tor_unlink(fname); + get_options_mutable()->DNSMappingFileMaxSize = 16; + + static const char big[] = GOOD_BODY(1500000200); + tt_assert(strlen(big) > 16); + tt_int_op(anyone_hosts_handle_fetch_response("peer", 200, "OK", + big, strlen(big), BASE_TIME), + OP_EQ, -1); + tt_int_op(file_status(fname), OP_NE, FN_FILE); + + done: + get_options_mutable()->DNSMappingFileMaxSize = 0; + if (fname) + tor_unlink(fname); + tor_free(fname); + UNMOCK(anyone_hosts_parse_and_verify_ex); +} + +struct testcase_t anyone_hosts_update_tests[] = { + { "disabled", test_anyone_hosts_update_disabled, TT_FORK, NULL, NULL }, + { "server_mode", test_anyone_hosts_update_server_mode, TT_FORK, + NULL, NULL }, + { "periodic_launch", test_anyone_hosts_update_periodic_launch, TT_FORK, + NULL, NULL }, + { "consensus_kick", test_anyone_hosts_update_consensus_kick, 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 }, + { "extract_addresses", test_anyone_hosts_update_extract_addresses, TT_FORK, + NULL, NULL }, + { "response_validation", test_anyone_hosts_update_response_validation, + TT_FORK, NULL, NULL }, + { "response_size_cap", test_anyone_hosts_update_response_size_cap, + TT_FORK, NULL, NULL }, + END_OF_TESTCASES +};