From 2c106ce773909dea40db99ecc5353426165caa7d Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Sat, 19 Sep 2026 08:01:57 +0200 Subject: [PATCH 1/2] path-lookup: introduce state_directory() as helper similar to runtime_directory() --- src/libsystemd/sd-path/path-lookup.c | 24 ++++++++++++++++++++++++ src/libsystemd/sd-path/path-lookup.h | 1 + 2 files changed, 25 insertions(+) diff --git a/src/libsystemd/sd-path/path-lookup.c b/src/libsystemd/sd-path/path-lookup.c index 1a49a6b46b1..5ebcc5c89fa 100644 --- a/src/libsystemd/sd-path/path-lookup.c +++ b/src/libsystemd/sd-path/path-lookup.c @@ -176,6 +176,30 @@ int state_directory_generic(RuntimeScope scope, const char *suffix, char **ret) } } +int state_directory(RuntimeScope scope, const char *fallback_suffix, char **ret) { + int r; + + assert(ret); + + /* Accept $STATE_DIRECTORY as authoritative, i.e. only works for our service's own state dir, as + * fallback_suffix is ignored if $STATE_DIRECTORY is set. + * + * If $STATE_DIRECTORY is missing, apply the fallback suffix to /var/lib/, or $XDG_STATE_HOME if we + * are in a user runtime scope. + * + * Return value indicates whether the suffix was applied or not. */ + + const char *e = secure_getenv("STATE_DIRECTORY"); + if (e) + return strdup_to(ret, e); + + r = state_directory_generic(scope, fallback_suffix, ret); + if (r < 0) + return r; + + return 1; +} + static const char* const user_data_unit_paths[] = { "/usr/local/lib/systemd/user", "/usr/local/share/systemd/user", diff --git a/src/libsystemd/sd-path/path-lookup.h b/src/libsystemd/sd-path/path-lookup.h index 4590f06ff8d..5afa495beea 100644 --- a/src/libsystemd/sd-path/path-lookup.h +++ b/src/libsystemd/sd-path/path-lookup.h @@ -63,6 +63,7 @@ int runtime_directory(RuntimeScope scope, const char *fallback_suffix, char **re int runtime_directory_resolve(RuntimeScope scope, const char *suffix, const char *identifier, char **ret); int runtime_directory_make(RuntimeScope scope, const char *suffix, const char *identifier, char **ret); int state_directory_generic(RuntimeScope scope, const char *suffix, char **ret); +int state_directory(RuntimeScope scope, const char *fallback_suffix, char **ret); /* We don't treat /etc/xdg/systemd/ in these functions as the xdg base dir spec suggests because we assume * that is a link to /etc/systemd/ anyway. */ From db34d7f47940a1bfef1e86226821d6979852c121 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 18 Sep 2026 19:03:00 +0200 Subject: [PATCH 2/2] report: add a simple geoip source --- docs/ENVIRONMENT.md | 5 + man/report-geoip.conf.xml | 101 +++ man/rules/meson.build | 8 + man/systemd-report-geoip@.service.xml | 156 ++++ meson.build | 4 + meson_options.txt | 4 + shell-completion/bash/systemd-analyze | 2 +- src/report/meson.build | 18 + src/report/report-geoip-server.c | 175 +++++ src/report/report-geoip.c | 686 ++++++++++++++++++ src/report/report-geoip.conf.in | 21 + src/report/report-geoip.h | 10 + .../fake-report-server.py | 40 + test/units/TEST-74-AUX-UTILS.report.sh | 125 ++++ units/meson.build | 8 + units/systemd-report-geoip.socket | 29 + units/systemd-report-geoip@.service.in | 42 ++ 17 files changed, 1433 insertions(+), 1 deletion(-) create mode 100644 man/report-geoip.conf.xml create mode 100644 man/systemd-report-geoip@.service.xml create mode 100644 src/report/report-geoip-server.c create mode 100644 src/report/report-geoip.c create mode 100644 src/report/report-geoip.conf.in create mode 100644 src/report/report-geoip.h create mode 100644 units/systemd-report-geoip.socket create mode 100644 units/systemd-report-geoip@.service.in diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 3b4c595f7e3..21b3cc81425 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -928,3 +928,8 @@ Tools using the Varlink protocol (such as `varlinkctl`) or sd-bus (such as notification carries no list of updated resources. This is useful to unconditionally trigger follow-up work such as relinking a kernel or recomputing a TPM policy. + +`systemd-report-geoip`: + +* `$SYSTEMD_REPORT_GEOIP_ENDPOINT` – takes an HTTP endpoint + URL. Overrides the GeoIP endpoint to use. diff --git a/man/report-geoip.conf.xml b/man/report-geoip.conf.xml new file mode 100644 index 00000000000..009d9871c6d --- /dev/null +++ b/man/report-geoip.conf.xml @@ -0,0 +1,101 @@ + + + + + + + report-geoip.conf + systemd + + + + report-geoip.conf + 5 + + + + report-geoip.conf + report-geoip.conf.d + GeoIP report metrics configuration file + + + + + /etc/systemd/report-geoip.conf + /run/systemd/report-geoip.conf + /usr/local/lib/systemd/report-geoip.conf + /usr/lib/systemd/report-geoip.conf + /etc/systemd/report-geoip.conf.d/*.conf + /run/systemd/report-geoip.conf.d/*.conf + /usr/local/lib/systemd/report-geoip.conf.d/*.conf + /usr/lib/systemd/report-geoip.conf.d/*.conf + + + + + Description + + This file configures the behavior of + systemd-report-geoip@.service8, + a metrics source for + systemd-report1 + reporting the approximate geographic location of the system. + + + + + + Options + + All options are configured in the [GeoIP] section: + + + + + Endpoint= + + The HTTP or HTTPS URL of the geolocation web service to query. The service must + answer a GET request with a JSON object in the format of the + reallyfreegeoip.org service, describing the + location of the address the request originates from. The field names used by + ipinfo.io (i.e. country, + region, continent in place of country_name, + region_name, continent_name) are understood too. Fields the + service does not provide are not reported. If assigned the empty string, the default is + restored. The default is chosen at compile time. + + + + + + + RefreshSec= + + Controls how often the geolocation data is refreshed from the endpoint, at most. + Takes a time span, see + systemd.time7 for + details. The acquired data is cached locally, and the endpoint is only contacted again once the + cached data is older than the specified time. If set to zero, the endpoint is contacted on every + request. If set to infinity, the endpoint is contacted only once, and the cached + data is used from then on. Defaults to 30min. + + + + + + + Use systemd-analyze cat-config systemd/report-geoip.conf + to display the full config. + + + + See Also + + systemd-report-geoip@.service8 + systemd-report1 + + + + diff --git a/man/rules/meson.build b/man/rules/meson.build index 00dd42e58d1..f08e26a1cf6 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -83,6 +83,10 @@ manpages = [ ['poweroff', '8', ['halt', 'reboot'], ''], ['pstore.conf', '5', ['pstore.conf.d'], 'ENABLE_PSTORE'], ['repart.d', '5', [], 'ENABLE_REPART'], + ['report-geoip.conf', + '5', + ['report-geoip.conf.d'], + 'ENABLE_REPORT HAVE_LIBCURL'], ['resolvectl', '1', ['resolvconf'], 'ENABLE_RESOLVE'], ['resolved.conf', '5', ['resolved.conf.d'], 'ENABLE_RESOLVE'], ['run0', '1', [], ''], @@ -1225,6 +1229,10 @@ manpages = [ '8', ['systemd-report-files', 'systemd-report-files.socket'], 'ENABLE_REPORT'], + ['systemd-report-geoip@.service', + '8', + ['systemd-report-geoip', 'systemd-report-geoip.socket'], + 'ENABLE_REPORT HAVE_LIBCURL'], ['systemd-report-hwmon@.service', '8', ['systemd-report-hwmon', 'systemd-report-hwmon.socket'], diff --git a/man/systemd-report-geoip@.service.xml b/man/systemd-report-geoip@.service.xml new file mode 100644 index 00000000000..8522c1577cd --- /dev/null +++ b/man/systemd-report-geoip@.service.xml @@ -0,0 +1,156 @@ + + + + + + + + systemd-report-geoip@.service + systemd + + + + systemd-report-geoip@.service + 8 + + + + systemd-report-geoip@.service + systemd-report-geoip.socket + systemd-report-geoip + Report the approximate geographic location of the system as a metric + + + + systemd-report-geoip@.service + systemd-report-geoip.socket + /usr/lib/systemd/systemd-report-geoip + + + + Description + + systemd-report-geoip@.service is a metrics source for + systemd-report1. It + reports the approximate geographic location of the system, as determined by a public IP geolocation + web service from the public IP address the system connects to it with. It implements the + io.systemd.Metrics Varlink interface on a socket linked into the + /run/systemd/report/ directory. + + The geolocation data is acquired via HTTP from the endpoint configured in + report-geoip.conf5. The + acquired data is cached, and only refreshed once the cache is older than the configured + RefreshSec= interval. If the endpoint cannot be reached, the cached data is reported + even if it is older than that. If no data is available at all, no metrics are reported. + + + + Metrics + + The following metrics are reported, all in the io.systemd.GeoIP. namespace. Fields + the geolocation service does not provide are not reported. + + + + io.systemd.GeoIP.PublicAddress + The public IP address of the system, as seen by the geolocation service. The address + is validated and reported in normalized form. + + + + + + io.systemd.GeoIP.ASN + io.systemd.GeoIP.ASName + io.systemd.GeoIP.ASDomain + The Autonomous System Number (e.g. AS3320) of the network the public + IP address belongs to, and the name and domain of the organization operating it. + + + + + + io.systemd.GeoIP.ContinentCode + io.systemd.GeoIP.ContinentName + The code (e.g. EU) and the name of the continent the system is + located in. + + + + + + io.systemd.GeoIP.CountryCode + io.systemd.GeoIP.CountryName + The ISO 3166-1 alpha-2 code and the name of the country the system is located + in. + + + + + + io.systemd.GeoIP.RegionCode + io.systemd.GeoIP.RegionName + The code and name of the region (state, province, …) the system is located + in. + + + + + + io.systemd.GeoIP.City + io.systemd.GeoIP.ZipCode + The city and postal code of the area the system is located in. + + + + + + io.systemd.GeoIP.Timezone + The IANA time zone name of the area the system is located in. + + + + + + io.systemd.GeoIP.Latitude + io.systemd.GeoIP.Longitude + The geographic coordinates of the system's location, in decimal degrees. + + + + + + io.systemd.GeoIP.Timestamp + The time at which the reported data was acquired from the geolocation service, in + microseconds since the UNIX epoch (CLOCK_REALTIME). When the data is served from + the cache this is the time the cache was last refreshed, not the time of the report. + + + + + + + + Privacy + + Note that refreshing the geolocation data means contacting a third party web service, which + thereby learns the public IP address of the system. The reported metrics identify the system's public IP + address and approximate location, and hence are sensitive. Only enable this service if this is acceptable + for the systems the reports are collected from. + + + + + + See Also + + systemd1 + systemd-report1 + report-geoip.conf5 + + + + diff --git a/meson.build b/meson.build index fdb4da08d5c..f592194e4da 100644 --- a/meson.build +++ b/meson.build @@ -981,6 +981,9 @@ conf.set_quoted('DNS_SERVERS', dns_servers) ntp_servers = get_option('ntp-servers') conf.set_quoted('NTP_SERVERS', ntp_servers) +geoip_endpoint = get_option('geoip-endpoint') +conf.set_quoted('GEOIP_ENDPOINT', geoip_endpoint) + default_locale = get_option('default-locale') conf.set_quoted('SYSTEMD_DEFAULT_LOCALE', default_locale) @@ -3149,6 +3152,7 @@ summary({ 'default LLMNR mode' : default_llmnr, 'default DNS servers' : dns_servers.split(' '), 'default NTP servers' : ntp_servers.split(' '), + 'default GeoIP endpoint' : geoip_endpoint, 'default net.naming_scheme= value' : default_net_naming_scheme, 'default KillUserProcesses= value' : kill_user_processes, 'default locale' : default_locale, diff --git a/meson_options.txt b/meson_options.txt index d3872d7ebb9..77a1433edf5 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -602,3 +602,7 @@ option('oss-fuzz', type : 'boolean', value : false, description : 'build against oss-fuzz') option('llvm-fuzz', type : 'boolean', value : false, description : 'build against LLVM libFuzzer') + +option('geoip-endpoint', type : 'string', + description : 'default URL of the IP geolocation web service queried by systemd-report-geoip', + value : 'https://reallyfreegeoip.org/json/') diff --git a/shell-completion/bash/systemd-analyze b/shell-completion/bash/systemd-analyze index 4ba660bb826..d00f4134fe5 100644 --- a/shell-completion/bash/systemd-analyze +++ b/shell-completion/bash/systemd-analyze @@ -107,7 +107,7 @@ _systemd_analyze() { local CONFIGS='locale.conf systemd/bootchart.conf systemd/coredump.conf systemd/journald.conf systemd/journal-remote.conf systemd/journal-upload.conf systemd/logind.conf - systemd/resolved.conf systemd/networkd.conf systemd/pstore.conf systemd/resolved.conf + systemd/resolved.conf systemd/networkd.conf systemd/pstore.conf systemd/report-geoip.conf systemd/resolved.conf systemd/sleep.conf systemd/system.conf systemd/timedated.conf systemd/timesyncd.conf systemd/user.conf udev/udev.conf' diff --git a/src/report/meson.build b/src/report/meson.build index f3a0831202b..baebc27530b 100644 --- a/src/report/meson.build +++ b/src/report/meson.build @@ -58,6 +58,17 @@ executables += [ 'report-files-server.c', ), }, + libexec_template + { + 'name' : 'systemd-report-geoip', + 'sources' : files( + 'report-geoip.c', + 'report-geoip-server.c', + ), + 'dependencies' : [ + libcurl_cflags, + ], + 'conditions' : ['HAVE_LIBCURL'], + }, libexec_template + { 'name' : 'systemd-report-hwmon', 'sources' : files( @@ -101,3 +112,10 @@ executables += [ ), }, ] + +custom_target( + input : 'report-geoip.conf.in', + output : 'report-geoip.conf', + command : [jinja2_cmdline, '@INPUT@', '@OUTPUT@'], + install : conf.get('HAVE_LIBCURL') == 1 and install_sysconfdir_samples, + install_dir : pkgconfigfiledir) diff --git a/src/report/report-geoip-server.c b/src/report/report-geoip-server.c new file mode 100644 index 00000000000..5625edf3ba0 --- /dev/null +++ b/src/report/report-geoip-server.c @@ -0,0 +1,175 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "build.h" +#include "conf-parser.h" +#include "dlopen-note.h" +#include "log.h" +#include "main-func.h" +#include "report-geoip.h" +#include "string-util.h" +#include "time-util.h" +#include "varlink-io.systemd.Metrics.h" +#include "varlink-util.h" +#include "verbs.h" +#include "web-util.h" + +#define REPORT_GEOIP_DEFAULT_REFRESH_USEC (30 * USEC_PER_MINUTE) + +COMMAND( + "systemd-report-geoip\0", + "Report the approximate geographic location of the system as a metric.", + .man_pages = "systemd-report-geoip@.service(8)\0", +); + +char *arg_endpoint = NULL; +usec_t arg_refresh_usec = REPORT_GEOIP_DEFAULT_REFRESH_USEC; + +STATIC_DESTRUCTOR_REGISTER(arg_endpoint, freep); + +static int config_parse_endpoint( + const char *unit, + const char *filename, + unsigned line, + const char *section, + unsigned section_line, + const char *lvalue, + int ltype, + const char *rvalue, + void *data, + void *userdata) { + + char **s = ASSERT_PTR(data); + + assert(filename); + assert(lvalue); + assert(rvalue); + + if (isempty(rvalue)) { + *s = mfree(*s); + return 0; + } + + if (!http_url_is_valid(rvalue)) { + log_syntax(unit, LOG_WARNING, filename, line, 0, + "%s= URL is not valid, ignoring: %s", lvalue, rvalue); + return 0; + } + + return free_and_strdup_warn(s, rvalue); +} + +static int parse_config(void) { + static const ConfigTableItem items[] = { + { "GeoIP", "Endpoint", config_parse_endpoint, 0, &arg_endpoint }, + { "GeoIP", "RefreshSec", config_parse_sec, 0, &arg_refresh_usec }, + {} + }; + + return config_parse_standard_file_with_dropins( + "systemd/report-geoip.conf", + "GeoIP\0", + config_item_table_lookup, items, + CONFIG_PARSE_WARN, + /* userdata= */ NULL); +} + +static int parse_environment(void) { + + const char *d = secure_getenv("SYSTEMD_REPORT_GEOIP_ENDPOINT"); + if (!d) + return 0; + + if (isempty(d)) { + arg_endpoint = mfree(arg_endpoint); + return 0; + } + + if (!http_url_is_valid(d)) { + log_warning("$SYSTEMD_REPORT_GEOIP_ENDPOINT is not a valid URL, ignoring: %s", d); + return 0; + } + + return free_and_strdup_warn(&arg_endpoint, d); +} + +static int vl_server(void) { + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *vs = NULL; + int r; + + r = varlink_server_new(&vs, /* flags= */ 0, /* userdata= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + r = sd_varlink_server_add_interface(vs, &vl_interface_io_systemd_Metrics); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interface: %m"); + + r = sd_varlink_server_bind_method_many( + vs, + "io.systemd.Metrics.List", vl_method_list_metrics, + "io.systemd.Metrics.Describe", vl_method_describe_metrics); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(vs); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + int r; + + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + + FOREACH_OPTION_OR_RETURN(c, &opts) + switch (c) { + OPTION_COMMON_HELP: + return command_print_help(); + + OPTION_COMMON_VERSION: + return version(); + + OPTION_COMMON_INTROSPECT_CLI: + return introspect_cli(SD_JSON_FORMAT_OFF); + } + + if (option_parser_get_n_args(&opts) > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program takes no arguments."); + + r = sd_varlink_invocation(SD_VARLINK_ALLOW_ACCEPT); + if (r < 0) + return log_error_errno(r, "Failed to check if invoked in Varlink mode: %m"); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program can only run as a Varlink service."); + return 1; +} + +static int run(int argc, char *argv[]) { + int r; + + LIBCURL_NOTE(required); + + log_setup(); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + (void) parse_config(); + (void) parse_environment(); + + return vl_server(); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/src/report/report-geoip.c b/src/report/report-geoip.c new file mode 100644 index 00000000000..afe1452753e --- /dev/null +++ b/src/report/report-geoip.c @@ -0,0 +1,686 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "curl-util.h" +#include "fd-util.h" +#include "fileio.h" +#include "in-addr-util.h" +#include "iovec-util.h" +#include "json-util.h" +#include "lock-util.h" +#include "log.h" +#include "metrics.h" +#include "mkdir.h" +#include "path-lookup.h" +#include "path-util.h" +#include "report-geoip.h" +#include "stat-util.h" +#include "string-util.h" +#include "time-util.h" +#include "version.h" +#include "web-util.h" + +#define METRIC_IO_SYSTEMD_GEOIP_PREFIX "io.systemd.GeoIP." + +/* The cache lives below the service's StateDirectory= (as passed via $STATE_DIRECTORY), so that it survives + * reboots, falling back to this path below /var/lib/ when started directly from the command line. All + * instances of the socket activated service share it, hence access is serialized via a lock file next to it. */ +#define REPORT_GEOIP_STATE_DIR_SUFFIX "systemd/report-geoip" +#define REPORT_GEOIP_CACHE_FILE_NAME "cache.json" + +/* Upper bound on the size of the answer we accept from the server, and of the cache file we are willing to + * read back. The actual answer is a couple of hundred bytes. */ +#define REPORT_GEOIP_ANSWER_MAX (64U * 1024U) + +/* The overall Varlink call is bounded by the client's timeout (30s for systemd-report), hence keep the + * network timeout well below that, so that we can still fall back to the cache in time. */ +#define REPORT_GEOIP_NETWORK_TIMEOUT_USEC (10 * USEC_PER_SEC) + +/* Follow HTTP redirects, but only a few of them. */ +#define REPORT_GEOIP_MAX_REDIRECTS 5L + +typedef enum GeoIPField { + GEOIP_FIELD_IP, + GEOIP_FIELD_ASN, + GEOIP_FIELD_AS_NAME, + GEOIP_FIELD_AS_DOMAIN, + GEOIP_FIELD_CONTINENT_CODE, + GEOIP_FIELD_CONTINENT_NAME, + GEOIP_FIELD_COUNTRY_CODE, + GEOIP_FIELD_COUNTRY_NAME, + GEOIP_FIELD_REGION_CODE, + GEOIP_FIELD_REGION_NAME, + GEOIP_FIELD_CITY, + GEOIP_FIELD_ZIP_CODE, + GEOIP_FIELD_TIME_ZONE, + GEOIP_FIELD_LATITUDE, + GEOIP_FIELD_LONGITUDE, + GEOIP_FIELD_TIMESTAMP, + _GEOIP_FIELD_MAX, + _GEOIP_FIELD_INVALID = -EINVAL, +} GeoIPField; + +typedef enum GeoIPValueType { + GEOIP_VALUE_STRING, /* A free-form string, reported as is */ + GEOIP_VALUE_ADDRESS, /* An IP address, stored in parsed form and reported in normalized form */ + GEOIP_VALUE_DOUBLE, /* A floating point number */ + GEOIP_VALUE_TIMESTAMP, /* A CLOCK_REALTIME timestamp in µs */ + _GEOIP_VALUE_TYPE_MAX, + _GEOIP_VALUE_TYPE_INVALID = -EINVAL, +} GeoIPValueType; + +/* The data we pick out of the server's JSON answer, plus when we acquired it. */ +typedef struct GeoIPData { + struct in_addr_data ip; + char *asn; + char *as_name; + char *as_domain; + char *continent_code; + char *continent_name; + char *country_code; + char *country_name; + char *region_code; + char *region_name; + char *city; + char *zip_code; + char *time_zone; + double latitude; + double longitude; + usec_t timestamp; /* CLOCK_REALTIME, when the data was downloaded (i.e. the mtime of the cache file) */ +} GeoIPData; + +#define GEOIP_DATA_INIT \ + (GeoIPData) { \ + .ip.family = AF_UNSPEC, \ + .latitude = NAN, \ + .longitude = NAN, \ + .timestamp = USEC_INFINITY, \ + } + +static void geoip_data_done(GeoIPData *d) { + assert(d); + + d->asn = mfree(d->asn); + d->as_name = mfree(d->as_name); + d->as_domain = mfree(d->as_domain); + d->continent_code = mfree(d->continent_code); + d->continent_name = mfree(d->continent_name); + d->country_code = mfree(d->country_code); + d->country_name = mfree(d->country_name); + d->region_code = mfree(d->region_code); + d->region_name = mfree(d->region_name); + d->city = mfree(d->city); + d->zip_code = mfree(d->zip_code); + d->time_zone = mfree(d->time_zone); +} + +static int geoip_data_parse(const char *text, GeoIPData *ret) { + /* Note that different providers return slightly different fields, hence the partially redundant + * definitions here. Additional fields the server might return are ignored. */ + static const sd_json_dispatch_field dispatch_table[] = { + { "ip", _SD_JSON_VARIANT_TYPE_INVALID, json_dispatch_in_addr_data, offsetof(GeoIPData, ip), SD_JSON_NULLABLE }, + { "asn", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, asn), SD_JSON_NULLABLE }, + { "as_name", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, as_name), SD_JSON_NULLABLE }, + { "as_domain", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, as_domain), SD_JSON_NULLABLE }, + { "continent_code", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, continent_code), SD_JSON_NULLABLE }, + { "continent", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, continent_name), SD_JSON_NULLABLE }, + { "continent_name", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, continent_name), SD_JSON_NULLABLE }, + { "country_code", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, country_code), SD_JSON_NULLABLE }, + { "country", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, country_name), SD_JSON_NULLABLE }, + { "country_name", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, country_name), SD_JSON_NULLABLE }, + { "region_code", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, region_code), SD_JSON_NULLABLE }, + { "region", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, region_name), SD_JSON_NULLABLE }, + { "region_name", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, region_name), SD_JSON_NULLABLE }, + { "city", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, city), SD_JSON_NULLABLE }, + { "zip_code", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, zip_code), SD_JSON_NULLABLE }, + { "time_zone", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(GeoIPData, time_zone), SD_JSON_NULLABLE }, + { "latitude", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_double, offsetof(GeoIPData, latitude), SD_JSON_NULLABLE }, + { "longitude", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_double, offsetof(GeoIPData, longitude), SD_JSON_NULLABLE }, + {}, + }; + + int r; + + assert(text); + assert(ret); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + r = sd_json_parse(text, SD_JSON_PARSE_MUST_BE_OBJECT, &v, /* reterr_line= */ NULL, /* reterr_column= */ NULL); + if (r < 0) + return r; + + _cleanup_(geoip_data_done) GeoIPData d = GEOIP_DATA_INIT; + r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_PERMISSIVE|SD_JSON_LOG, &d); + if (r < 0) + return r; + + *ret = TAKE_STRUCT(d); + return 0; +} + +static size_t write_callback(char *buf, size_t size, size_t nmemb, void *userdata) { + struct iovec *answer = ASSERT_PTR(userdata); + + assert(size == 1); /* The docs say that this is always true. */ + + if (nmemb == 0) + return 0; + + /* The answer is accumulated in a single contiguous buffer, so that the number of chunks the server + * delivers it in doesn't matter, only the total size does. */ + size_t new_size = size_add(answer->iov_len, nmemb); + if (new_size > REPORT_GEOIP_ANSWER_MAX) { + log_debug("Server answer too long (%zu > %u), refusing.", new_size, REPORT_GEOIP_ANSWER_MAX); + return 0; + } + + if (memchr(buf, 0, nmemb)) { + log_debug("Server answer contains an embedded NUL, refusing."); + return 0; + } + + if (!iovec_append(answer, &IOVEC_MAKE(buf, nmemb))) { + log_oom_debug(); + return 0; /* Returning < nmemb signals failure */ + } + + return nmemb; +} + +static int geoip_fetch(const char *endpoint, char **ret) { + _cleanup_(iovec_done) struct iovec answer = {}; + _cleanup_(curl_slist_free_allp) struct curl_slist *header = NULL; + char error[CURL_ERROR_SIZE] = {}; + int r; + + assert(endpoint); + assert(ret); + + /* Downloads the JSON answer from the configured endpoint, and returns it as NUL-terminated string. */ + + /* The endpoint from the configuration file is validated when parsed, but the compile-time default is + * not, hence check here, so that both take the same path. */ + if (!http_url_is_valid(endpoint)) + return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "Endpoint URL '%s' is not valid, refusing.", endpoint); + + r = dlopen_curl(LOG_DEBUG); + if (r < 0) + return r; + + r = curl_append_to_header(&header, STRV_MAKE("Accept: application/json")); + if (r < 0) + return r; + + _cleanup_(curl_easy_cleanupp) CURL *curl = sym_curl_easy_init(); + if (!curl) + return -ENOSR; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_URL, endpoint)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_HTTPGET, 1L)) + return -EXFULL; + + /* Only ever talk HTTP(S), for the initial request as well as for any redirects we follow. */ +#if LIBCURL_VERSION_NUM >= 0x075500 /* libcurl 7.85.0 */ + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_PROTOCOLS_STR, "HTTP,HTTPS")) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_REDIR_PROTOCOLS_STR, "HTTP,HTTPS")) + return -EXFULL; +#else + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_PROTOCOLS, CURLPROTO_HTTP|CURLPROTO_HTTPS)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP|CURLPROTO_HTTPS)) + return -EXFULL; +#endif + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_FOLLOWLOCATION, 1L)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_MAXREDIRS, REPORT_GEOIP_MAX_REDIRECTS)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_TIMEOUT_MS, + (long) DIV_ROUND_UP(REPORT_GEOIP_NETWORK_TIMEOUT_USEC, USEC_PER_MSEC))) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_NOSIGNAL, 1L)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_ERRORBUFFER, error)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_WRITEFUNCTION, write_callback)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_WRITEDATA, &answer)) + return -EXFULL; + + if (!easy_setopt(curl, LOG_DEBUG, CURLOPT_HTTPHEADER, header)) + return -EXFULL; + + (void) easy_setopt(curl, LOG_DEBUG, CURLOPT_USERAGENT, "systemd-report-geoip " GIT_VERSION); + + if (DEBUG_LOGGING) + (void) easy_setopt(curl, LOG_DEBUG, CURLOPT_VERBOSE, 1L); + + CURLcode code = sym_curl_easy_perform(curl); + if (code != CURLE_OK) + return log_debug_errno(SYNTHETIC_ERRNO(EIO), + "Download from '%s' failed: %s", endpoint, + empty_to_null(&error[0]) ?: sym_curl_easy_strerror(code)); + + long status; + code = sym_curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + if (code != CURLE_OK) + return log_debug_errno(SYNTHETIC_ERRNO(EUCLEAN), + "Failed to retrieve response code: %s", sym_curl_easy_strerror(code)); + + if (status < 200 || status >= 300) + return log_debug_errno(SYNTHETIC_ERRNO(EIO), + "Download from '%s' failed with HTTP status %ld.", endpoint, status); + if (status != 200) + return log_debug_errno(SYNTHETIC_ERRNO(EIO), + "Unexpected HTTP status from '%s': %ld.", endpoint, status); + + r = make_cstring(answer.iov_base, answer.iov_len, MAKE_CSTRING_REFUSE_TRAILING_NUL, ret); + if (r < 0) + return log_debug_errno(r, "Failed to turn acquired data into C string: %m"); + + return 0; +} + +static int geoip_cache_path(char **ret) { + int r; + + assert(ret); + + _cleanup_free_ char *dir = NULL; + r = state_directory(RUNTIME_SCOPE_SYSTEM, REPORT_GEOIP_STATE_DIR_SUFFIX, &dir); + if (r < 0) + return r; + + _cleanup_free_ char *path = path_join(dir, REPORT_GEOIP_CACHE_FILE_NAME); + if (!path) + return -ENOMEM; + + *ret = TAKE_PTR(path); + return 0; +} + +static int geoip_cache_load(const char *path, char **ret_text, usec_t *ret_mtime) { + int r; + + assert(path); + assert(ret_text); + assert(ret_mtime); + + /* Reads the cache file, and returns its modification time, i.e. when the data was downloaded. */ + + _cleanup_close_ int fd = open(path, O_RDONLY|O_CLOEXEC|O_NOCTTY); + if (fd < 0) + return -errno; + + struct stat st; + if (fstat(fd, &st) < 0) + return -errno; + + r = stat_verify_regular(&st); + if (r < 0) + return r; + + _cleanup_free_ char *text = NULL; + r = read_full_file_full( + fd, + /* filename= */ NULL, + /* offset= */ UINT64_MAX, + REPORT_GEOIP_ANSWER_MAX, + READ_FULL_FILE_FAIL_WHEN_LARGER, + /* bind_name= */ NULL, + &text, + /* ret_size= */ NULL); + if (r < 0) + return r; + + *ret_text = TAKE_PTR(text); + *ret_mtime = timespec_load(&st.st_mtim); + return 0; +} + +static int geoip_prepare_cache(char **ret_cache_path, LockFile *ret_lock_file) { + int r; + + assert(ret_cache_path); + assert(ret_lock_file); + + _cleanup_free_ char *cache_path = NULL; + r = geoip_cache_path(&cache_path); + if (r < 0) { + log_warning_errno(r, "Failed to determine cache file path, ignoring: %m"); + *ret_cache_path = NULL; + *ret_lock_file = LOCK_FILE_INIT; + return 0; + } + + /* Create the parent directory before we try to create the lock file. Ignore failures. */ + r = mkdir_parents(cache_path, 0755); + if (r < 0) + log_warning_errno(r, "Failed to create parent directory of '%s', ignoring: %m", cache_path); + + /* Serialize concurrent instances, so that only one of them talks to the network and the others pick + * up the cache it leaves behind. Note that we don't fail here, since caching is just an + * optimization here, it's not load bearing, and not having the lock won't corrupt the cache + * as we write the cache file via atomic fs updates. Not having the lock just means we might + * possibly go to the network unnecessarily. (When running this backend for testing purposes + * we won't be able to lock here, since we likely lack privileges, hence the graceful + * handling here.) */ + _cleanup_(release_lock_file) LockFile lock = LOCK_FILE_INIT; + r = make_lock_file_for(cache_path, LOCK_EX, &lock); + if (r < 0) + log_warning_errno(r, "Failed to take lock on '%s', ignoring: %m", cache_path); + + *ret_cache_path = TAKE_PTR(cache_path); + *ret_lock_file = TAKE_GENERIC(lock, LockFile, LOCK_FILE_INIT); + return 1; +} + +static int geoip_data_acquire(GeoIPData *ret) { + int r; + + assert(ret); + + /* Acquires the geolocation data, preferably from the cache if it is recent enough, otherwise from + * the network. A stale cache is used as fallback if the network is unavailable. Note that we + * consider the cache optional, hence any failure of the cache is handled gracefully. */ + + const char *endpoint = arg_endpoint ?: GEOIP_ENDPOINT; /* compile-time default, see -Dgeoip-endpoint= */ + + _cleanup_(release_lock_file) LockFile lock = LOCK_FILE_INIT; + _cleanup_free_ char *cache_path = NULL; + r = geoip_prepare_cache(&cache_path, &lock); + if (r < 0) + return r; + + _cleanup_free_ char *cached = NULL; + usec_t mtime = USEC_INFINITY, age = USEC_INFINITY; + if (cache_path) { + /* First, read cache if we have one */ + r = geoip_cache_load(cache_path, &cached, &mtime); + if (r < 0) + log_full_errno(r == -ENOENT ? LOG_DEBUG : LOG_WARNING, r, + "Failed to read cache file '%s', ignoring: %m", cache_path); + else + age = usec_sub_unsigned(now(CLOCK_REALTIME), mtime); /* A cache from the future counts as brand new. */ + + if (age < arg_refresh_usec) { + assert(cached); + + _cleanup_(geoip_data_done) GeoIPData d = GEOIP_DATA_INIT; + r = geoip_data_parse(cached, &d); + if (r < 0) + log_warning_errno(r, "Failed to parse cache file '%s', ignoring: %m", cache_path); + else { + log_debug("Using cached geolocation data (age: %s).", FORMAT_TIMESPAN(age, USEC_PER_SEC)); + d.timestamp = mtime; + *ret = TAKE_STRUCT(d); + return 0; + } + + /* The cache is unusable, don't fall back to it below either. */ + cached = mfree(cached); + } + } + + /* Second: Cache wasn't available or not fresh, let's get the data from the network */ + _cleanup_free_ char *fresh = NULL; + r = geoip_fetch(endpoint, &fresh); + if (r < 0) + log_warning_errno(r, "Failed to download geolocation data from '%s', ignoring: %m", endpoint); + else { + _cleanup_(geoip_data_done) GeoIPData d = GEOIP_DATA_INIT; + + r = geoip_data_parse(fresh, &d); + if (r < 0) + log_warning_errno(r, "Failed to parse geolocation data from '%s', ignoring: %m", endpoint); + else { + /* Sample the timestamp only now, i.e. after the (potentially slow) download completed, + * and stamp the cache file with precisely this value, so that the mtime we report on + * later requests matches the timestamp we report right now. */ + usec_t n = now(CLOCK_REALTIME); + + /* Only cache what we managed to parse, so that we never fall back to garbage. */ + if (cache_path) { + r = write_string_file_full(AT_FDCWD, cache_path, fresh, + WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_ATOMIC|WRITE_STRING_FILE_MKDIR_0755, + TIMESPEC_STORE(n), /* label_fn= */ NULL); + if (r < 0) + log_warning_errno(r, "Failed to write cache file '%s', ignoring: %m", cache_path); + } + + d.timestamp = n; + *ret = TAKE_STRUCT(d); + return 0; + } + } + + /* Third, the network didn't work, but we have some stale cache data, let's use that then, because we + * have nothing better. */ + if (cached) { + _cleanup_(geoip_data_done) GeoIPData d = GEOIP_DATA_INIT; + + r = geoip_data_parse(cached, &d); + if (r < 0) + log_warning_errno(r, "Failed to parse cache file '%s', ignoring: %m", cache_path); + else { + log_notice("Using stale cached geolocation data (age: %s).", FORMAT_TIMESPAN(age, USEC_PER_SEC)); + d.timestamp = mtime; + *ret = TAKE_STRUCT(d); + return 0; + } + } + + log_debug("No geolocation data available, reporting nothing."); + *ret = GEOIP_DATA_INIT; + return 0; +} + +static int geoip_generate(const MetricFamily mf[static _GEOIP_FIELD_MAX], sd_varlink *link, void *userdata) { + /* Describes how each member of GeoIPData is turned into a metric. The order must match the metric + * family table below. */ + static const struct { + GeoIPValueType value_type; + size_t offset; /* Offset of the corresponding member in GeoIPData */ + } field_table[_GEOIP_FIELD_MAX] = { + [GEOIP_FIELD_IP] = { GEOIP_VALUE_ADDRESS, offsetof(GeoIPData, ip) }, + [GEOIP_FIELD_ASN] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, asn) }, + [GEOIP_FIELD_AS_NAME] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, as_name) }, + [GEOIP_FIELD_AS_DOMAIN] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, as_domain) }, + [GEOIP_FIELD_CONTINENT_CODE] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, continent_code) }, + [GEOIP_FIELD_CONTINENT_NAME] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, continent_name) }, + [GEOIP_FIELD_COUNTRY_CODE] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, country_code) }, + [GEOIP_FIELD_COUNTRY_NAME] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, country_name) }, + [GEOIP_FIELD_REGION_CODE] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, region_code) }, + [GEOIP_FIELD_REGION_NAME] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, region_name) }, + [GEOIP_FIELD_CITY] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, city) }, + [GEOIP_FIELD_ZIP_CODE] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, zip_code) }, + [GEOIP_FIELD_TIME_ZONE] = { GEOIP_VALUE_STRING, offsetof(GeoIPData, time_zone) }, + [GEOIP_FIELD_LATITUDE] = { GEOIP_VALUE_DOUBLE, offsetof(GeoIPData, latitude) }, + [GEOIP_FIELD_LONGITUDE] = { GEOIP_VALUE_DOUBLE, offsetof(GeoIPData, longitude) }, + [GEOIP_FIELD_TIMESTAMP] = { GEOIP_VALUE_TIMESTAMP, offsetof(GeoIPData, timestamp) }, + }; + + int r; + + assert(mf && mf[0].name); + assert(link); + + _cleanup_(geoip_data_done) GeoIPData d = GEOIP_DATA_INIT; + r = geoip_data_acquire(&d); + if (r < 0) + return r; + + for (GeoIPField f = 0; f < _GEOIP_FIELD_MAX; f++) { + const void *member = (const uint8_t*) &d + field_table[f].offset; + + switch (field_table[f].value_type) { + + case GEOIP_VALUE_STRING: { + const char *v = *(const char* const*) member; + if (isempty(v)) + continue; + + if (!string_is_safe(v, STRING_ALLOW_BACKSLASHES|STRING_ALLOW_QUOTES|STRING_ALLOW_GLOBS)) { + log_debug("Value of metric '%s' contains unsafe characters, ignoring.", mf[f].name); + continue; + } + + r = metric_build_send_string(mf + f, link, /* object= */ NULL, v, /* fields= */ NULL); + if (r < 0) + return r; + break; + } + + case GEOIP_VALUE_ADDRESS: { + const struct in_addr_data *a = member; + if (!IN_SET(a->family, AF_INET, AF_INET6)) + continue; + + /* Report the canonical formatting of the parsed address, never the string as we got it. */ + r = metric_build_send_string(mf + f, link, /* object= */ NULL, IN_ADDR_TO_STRING(a->family, &a->address), /* fields= */ NULL); + if (r < 0) + return r; + break; + } + + case GEOIP_VALUE_DOUBLE: { + double v = *(const double*) member; + if (!isfinite(v)) + continue; + + r = metric_build_send_double(mf + f, link, /* object= */ NULL, v, /* fields= */ NULL); + if (r < 0) + return r; + break; + } + + case GEOIP_VALUE_TIMESTAMP: { + usec_t v = *(const usec_t*) member; + if (v == USEC_INFINITY) + continue; + + r = metric_build_send_unsigned(mf + f, link, /* object= */ NULL, v, /* fields= */ NULL); + if (r < 0) + return r; + break; + } + + default: + assert_not_reached(); + } + } + + return 0; +} + +/* The metrics we report. Only the first entry carries the generating function, which reports all of them in + * one go. The order must match the field table in geoip_generate() above. */ +static const MetricFamily metric_family_table[_GEOIP_FIELD_MAX + 1] = { + [GEOIP_FIELD_IP] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "PublicAddress", + "Public IP address of the system as seen by the geolocation service, in normalized form", + METRIC_FAMILY_TYPE_STRING, + .generate = geoip_generate, + }, + [GEOIP_FIELD_ASN] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "ASN", + "Autonomous System Number of the IP address", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_AS_NAME] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "ASName", + "Associated name of the ASN", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_AS_DOMAIN] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "ASDomain", + "Associated domain of the ASN", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_CONTINENT_CODE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "ContinentCode", + "Code of the continent the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_CONTINENT_NAME] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "ContinentName", + "Name of the continent the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_COUNTRY_CODE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "CountryCode", + "ISO 3166-1 alpha-2 code of the country the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_COUNTRY_NAME] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "CountryName", + "Name of the country the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_REGION_CODE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "RegionCode", + "Code of the region (state, province, …) the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_REGION_NAME] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "RegionName", + "Name of the region (state, province, …) the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_CITY] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "City", + "Name of the city the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_ZIP_CODE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "ZipCode", + "Postal code of the area the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_TIME_ZONE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "Timezone", + "IANA time zone name of the area the system is located in", + METRIC_FAMILY_TYPE_STRING, + }, + [GEOIP_FIELD_LATITUDE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "Latitude", + "Latitude of the system's location in decimal degrees", + METRIC_FAMILY_TYPE_GAUGE, + }, + [GEOIP_FIELD_LONGITUDE] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "Longitude", + "Longitude of the system's location in decimal degrees", + METRIC_FAMILY_TYPE_GAUGE, + }, + [GEOIP_FIELD_TIMESTAMP] = { + METRIC_IO_SYSTEMD_GEOIP_PREFIX "Timestamp", + "CLOCK_REALTIME microseconds at which the geolocation data was acquired from the service", + METRIC_FAMILY_TYPE_GAUGE, + }, + [_GEOIP_FIELD_MAX] = {}, /* terminator */ +}; + +int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return metrics_method_list(metric_family_table, link, parameters, flags, /* userdata= */ NULL); +} + +int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return metrics_method_describe(metric_family_table, link, parameters, flags, /* userdata= */ NULL); +} diff --git a/src/report/report-geoip.conf.in b/src/report/report-geoip.conf.in new file mode 100644 index 00000000000..4ad8a0b92b0 --- /dev/null +++ b/src/report/report-geoip.conf.in @@ -0,0 +1,21 @@ +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# Entries in this file show the compile time defaults. Local configuration +# should be created by either modifying this file (or a copy of it placed in +# /etc/ if the original file is shipped in /usr/), or by creating "drop-ins" in +# the /etc/systemd/report-geoip.conf.d/ directory. The latter is generally +# recommended. Defaults can be restored by simply deleting the main +# configuration file and all drop-ins located in /etc/. +# +# Use 'systemd-analyze cat-config systemd/report-geoip.conf' to display the full config. +# +# See report-geoip.conf(5) for details. + +[GeoIP] +#Endpoint={{GEOIP_ENDPOINT}} +#RefreshSec=30min diff --git a/src/report/report-geoip.h b/src/report/report-geoip.h new file mode 100644 index 00000000000..44a291976ef --- /dev/null +++ b/src/report/report-geoip.h @@ -0,0 +1,10 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "forward.h" + +extern char *arg_endpoint; +extern usec_t arg_refresh_usec; + +int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); +int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); diff --git a/test/integration-tests/TEST-74-AUX-UTILS/TEST-74-AUX-UTILS.units/fake-report-server.py b/test/integration-tests/TEST-74-AUX-UTILS/TEST-74-AUX-UTILS.units/fake-report-server.py index ee0b2294c54..6116a089c5a 100755 --- a/test/integration-tests/TEST-74-AUX-UTILS/TEST-74-AUX-UTILS.units/fake-report-server.py +++ b/test/integration-tests/TEST-74-AUX-UTILS/TEST-74-AUX-UTILS.units/fake-report-server.py @@ -52,6 +52,41 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(response) + def do_GET(self): + # Mimic the JSON answer of a freegeoip-style geolocation service, for + # testing systemd-report-geoip. The address is deliberately not in + # canonical form (and might not even be an address), to verify that + # the client validates and normalizes it. + if self.path != '/json/': + self.send_error(404, 'Not Found') + return + + response = json.dumps( + { + 'ip': args.geoip_address, + 'asn': 'AS3320', + 'as_name': 'Deutsche Telekom AG', + 'as_domain': 'telekom.de', + 'continent_code': 'EU', + 'continent_name': 'Europe', + 'country_code': 'DE', + 'country_name': 'Germany', + 'region_code': 'BE', + 'region_name': 'State of Berlin', + 'city': 'Berlin', + 'zip_code': '10785', + 'time_zone': 'Europe/Berlin', + 'latitude': 52.5061, + 'longitude': 13.3684, + 'metro_code': 0, + } + ).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', len(response)) + self.end_headers() + self.wfile.write(response) + def log_message(self, fmt, *args): print(f'{self.address_string()} - {fmt % args}') @@ -60,6 +95,11 @@ parser = argparse.ArgumentParser() parser.add_argument('--port', type=int, default=8089) parser.add_argument('--cert', help='TLS certificate file') parser.add_argument('--key', help='TLS private key file') +parser.add_argument( + '--geoip-address', + default='2001:0DB8:0000:0000:0000:0000:0000:0001', + help='IP address to return in the geolocation answer', +) args = parser.parse_args() server = HTTPServer(('', args.port), Handler) diff --git a/test/units/TEST-74-AUX-UTILS.report.sh b/test/units/TEST-74-AUX-UTILS.report.sh index 91d03b4919d..f192e05fcc8 100755 --- a/test/units/TEST-74-AUX-UTILS.report.sh +++ b/test/units/TEST-74-AUX-UTILS.report.sh @@ -4,6 +4,9 @@ set -eux set -o pipefail shopt -s nullglob +# shellcheck source=test/units/test-control.sh +. "$(dirname "$0")"/test-control.sh + # Unset $PAGER so we don't have to use --no-pager everywhere export PAGER= @@ -669,6 +672,128 @@ rm -f /run/systemd/report.files/fifoshadowed /var/lib/systemd/report.files/fifos rm -f /run/systemd/report.files/maskedfile /var/lib/systemd/report.files/maskedfile /var/lib/systemd/report.files/dirshadowed rmdir /run/systemd/report.files/dirshadowed +# ----------------------------------------------------------------------------- +# Test the systemd-report-geoip@.service metric provider: the geolocation data +# is downloaded from the configured endpoint (our fake server here), cached, and +# exposed as io.systemd.GeoIP.* metrics. The provider is only built with libcurl +# support, so skip if unavailable. +geoip_cleanup() { + set +e + systemctl stop systemd-report-geoip.socket fake-geoip-server fake-geoip-server-bogus + rm -rf /run/systemd/report-geoip.conf.d + rm -f /var/lib/systemd/report-geoip/cache.json + set -e +} + +testcase_geoip() { + local geoip_metrics geoip_describe family + + if ! systemctl cat systemd-report-geoip.socket &>/dev/null; then + echo "systemd-report-geoip.socket is not installed, skipping geoip metrics test." + return 0 + fi + + trap geoip_cleanup RETURN + + systemd-run -p Type=notify --unit=fake-geoip-server "$FAKE_SERVER" --port=8092 + + mkdir -p /run/systemd/report-geoip.conf.d + cat >/run/systemd/report-geoip.conf.d/99-test.conf </dev/null; } + + # The address must be reported in normalized form, not as the server sent it. + [ "$(geoip_value io.systemd.GeoIP.PublicAddress)" = "2001:db8::1" ] + [ "$(geoip_value io.systemd.GeoIP.ASN)" = "AS3320" ] + [ "$(geoip_value io.systemd.GeoIP.ASName)" = "Deutsche Telekom AG" ] + [ "$(geoip_value io.systemd.GeoIP.ASDomain)" = "telekom.de" ] + [ "$(geoip_value io.systemd.GeoIP.ContinentCode)" = "EU" ] + [ "$(geoip_value io.systemd.GeoIP.ContinentName)" = "Europe" ] + [ "$(geoip_value io.systemd.GeoIP.CountryCode)" = "DE" ] + [ "$(geoip_value io.systemd.GeoIP.CountryName)" = "Germany" ] + [ "$(geoip_value io.systemd.GeoIP.RegionCode)" = "BE" ] + [ "$(geoip_value io.systemd.GeoIP.RegionName)" = "State of Berlin" ] + [ "$(geoip_value io.systemd.GeoIP.City)" = "Berlin" ] + [ "$(geoip_value io.systemd.GeoIP.ZipCode)" = "10785" ] + [ "$(geoip_value io.systemd.GeoIP.Timezone)" = "Europe/Berlin" ] + geoip_number_is io.systemd.GeoIP.Latitude 52.5061 + geoip_number_is io.systemd.GeoIP.Longitude 13.3684 + # Freshly downloaded: the acquisition timestamp is "now", i.e. not older than a minute. + [ "$(geoip_value io.systemd.GeoIP.Timestamp)" -gt "$(( ($(date +%s) - 60) * 1000000 ))" ] + + # The obsolete metro_code field must not be reported. + (! echo "$geoip_metrics" | grep -i metro >/dev/null) + + # The answer must have been cached. + test -s /var/lib/systemd/report-geoip/cache.json + jq . /var/lib/systemd/report-geoip/cache.json + + # Describe must list all sixteen metric families, with the right types. + geoip_describe="$(varlinkctl call --more /run/systemd/report/io.systemd.GeoIP io.systemd.Metrics.Describe {})" + [ "$(echo "$geoip_describe" | jq --seq -r '.name' | grep -c '^io\.systemd\.GeoIP\.')" = 16 ] + for family in PublicAddress ASN ASName ASDomain ContinentCode ContinentName CountryCode CountryName \ + RegionCode RegionName City ZipCode Timezone; do + echo "$geoip_describe" | jq --seq -r "select(.name == \"io.systemd.GeoIP.$family\") | .type" | grep -wx string >/dev/null + done + for family in Latitude Longitude Timestamp; do + echo "$geoip_describe" | jq --seq -r "select(.name == \"io.systemd.GeoIP.$family\") | .type" | grep -wx gauge >/dev/null + done + + # With the endpoint gone the (still fresh) cache must be used, and the acquisition timestamp must then + # be the cache file's modification time. + systemctl stop fake-geoip-server + geoip_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.GeoIP io.systemd.Metrics.List {})" + [ "$(geoip_value io.systemd.GeoIP.PublicAddress)" = "2001:db8::1" ] + [ "$(geoip_value io.systemd.GeoIP.ASN)" = "AS3320" ] + [ "$(geoip_value io.systemd.GeoIP.City)" = "Berlin" ] + [ "$(( $(geoip_value io.systemd.GeoIP.Timestamp) / 1000000 ))" = "$(stat -c %Y /var/lib/systemd/report-geoip/cache.json)" ] + + # Force a refresh: the download fails, so the stale cache must be used. + cat >/run/systemd/report-geoip.conf.d/99-test.conf </run/systemd/report-geoip.conf.d/99-test.conf <&1) | grep NoSuchMetric >/dev/null +} + +# Only the geoip test is organized as a test case so far, the others still run inline. +run_testcases + # ----------------------------------------------------------------------------- # Test report signing through the plain software backend, driven entirely via # the io.systemd.Report Varlink interface (the GenerateSigned method). The plain diff --git a/units/meson.build b/units/meson.build index c04f2905413..b1699f7ab3f 100644 --- a/units/meson.build +++ b/units/meson.build @@ -841,6 +841,14 @@ units = [ 'file' : 'systemd-report-files@.service.in', 'conditions' : ['ENABLE_REPORT'], }, + { + 'file' : 'systemd-report-geoip.socket', + 'conditions' : ['ENABLE_REPORT', 'HAVE_LIBCURL'], + }, + { + 'file' : 'systemd-report-geoip@.service.in', + 'conditions' : ['ENABLE_REPORT', 'HAVE_LIBCURL'], + }, { 'file' : 'systemd-report-hwmon.socket', 'conditions' : ['ENABLE_REPORT'], diff --git a/units/systemd-report-geoip.socket b/units/systemd-report-geoip.socket new file mode 100644 index 00000000000..0b253d1a62d --- /dev/null +++ b/units/systemd-report-geoip.socket @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Report GeoIP Metrics Socket +Documentation=man:systemd-report-geoip@.service(8) +DefaultDependencies=no +Before=sockets.target shutdown.target +Conflicts=shutdown.target + +[Socket] +ListenStream=/run/systemd/report/io.systemd.GeoIP +FileDescriptorName=varlink +SocketMode=0666 +Accept=yes +MaxConnectionsPerSource=16 +RemoveOnStop=yes +XAttrEntryPoint=user.varlink=entrypoint +XAttrListen=user.varlink=listen +XAttrAccept=user.varlink=server + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-report-geoip@.service.in b/units/systemd-report-geoip@.service.in new file mode 100644 index 00000000000..9d209c8dd0f --- /dev/null +++ b/units/systemd-report-geoip@.service.in @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Report GeoIP Metrics +Documentation=man:systemd-report-geoip@.service(8) +DefaultDependencies=no +Conflicts=shutdown.target +Before=shutdown.target + +[Service] +CapabilityBoundingSet= +DeviceAllow= +DynamicUser=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +PrivateDevices=yes +PrivateIPC=yes +PrivateTmp=disconnected +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +StateDirectory=systemd/report-geoip +SystemCallArchitectures=native +SystemCallErrorNumber=EPERM +SystemCallFilter=@system-service + +ExecStart={{LIBEXECDIR}}/systemd-report-geoip