report: add simple geoip metric (#43804)

When collecting reports it's sometimes interesting to know where the
node reporting is actually located. Add a simple report source that adds
that, backed by a simple geoip HTTP request.

This is opt-in, due to privacy and so on.
This commit is contained in:
Lennart Poettering authored and GitHub committed 2026-10-17 10:40:38 +02:00
commit 4154aaeedb
19 files changed
+1458 -1

No files matched your search

+5
View File
@@ -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.
+101
View File
@@ -0,0 +1,101 @@
<?xml version='1.0'?>
<!DOCTYPE refentry PUBLIC "-//OASIS//DTD DocBook XML V4.5//EN"
"http://www.oasis-open.org/docbook/xml/4.5/docbookx.dtd">
<!-- SPDX-License-Identifier: LGPL-2.1-or-later -->
<refentry id="report-geoip.conf" conditional="ENABLE_REPORT HAVE_LIBCURL"
xmlns:xi="http://www.w3.org/2001/XInclude">
<refentryinfo>
<title>report-geoip.conf</title>
<productname>systemd</productname>
</refentryinfo>
<refmeta>
<refentrytitle>report-geoip.conf</refentrytitle>
<manvolnum>5</manvolnum>
</refmeta>
<refnamediv>
<refname>report-geoip.conf</refname>
<refname>report-geoip.conf.d</refname>
<refpurpose>GeoIP report metrics configuration file</refpurpose>
</refnamediv>
<refsynopsisdiv>
<para><simplelist>
<member><filename>/etc/systemd/report-geoip.conf</filename></member>
<member><filename>/run/systemd/report-geoip.conf</filename></member>
<member><filename>/usr/local/lib/systemd/report-geoip.conf</filename></member>
<member><filename>/usr/lib/systemd/report-geoip.conf</filename></member>
<member><filename>/etc/systemd/report-geoip.conf.d/*.conf</filename></member>
<member><filename>/run/systemd/report-geoip.conf.d/*.conf</filename></member>
<member><filename>/usr/local/lib/systemd/report-geoip.conf.d/*.conf</filename></member>
<member><filename>/usr/lib/systemd/report-geoip.conf.d/*.conf</filename></member>
</simplelist></para>
</refsynopsisdiv>
<refsect1>
<title>Description</title>
<para>This file configures the behavior of
<citerefentry><refentrytitle>systemd-report-geoip@.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>,
a metrics source for
<citerefentry><refentrytitle>systemd-report</refentrytitle><manvolnum>1</manvolnum></citerefentry>
reporting the approximate geographic location of the system.</para>
</refsect1>
<xi:include href="standard-conf.xml" xpointer="main-conf" />
<refsect1>
<title>Options</title>
<para>All options are configured in the [GeoIP] section:</para>
<variablelist class='config-directives'>
<varlistentry>
<term><varname>Endpoint=</varname></term>
<listitem><para>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
<ulink url="https://reallyfreegeoip.org/">reallyfreegeoip.org</ulink> service, describing the
location of the address the request originates from. The field names used by
<ulink url="https://ipinfo.io/">ipinfo.io</ulink> (i.e. <literal>country</literal>,
<literal>region</literal>, <literal>continent</literal> in place of <literal>country_name</literal>,
<literal>region_name</literal>, <literal>continent_name</literal>) 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.</para>
<xi:include href="version-info.xml" xpointer="v263"/>
</listitem>
</varlistentry>
<varlistentry>
<term><varname>RefreshSec=</varname></term>
<listitem><para>Controls how often the geolocation data is refreshed from the endpoint, at most.
Takes a time span, see
<citerefentry><refentrytitle>systemd.time</refentrytitle><manvolnum>7</manvolnum></citerefentry> 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 <literal>infinity</literal>, the endpoint is contacted only once, and the cached
data is used from then on. Defaults to <literal>30min</literal>.</para>
<xi:include href="version-info.xml" xpointer="v263"/>
</listitem>
</varlistentry>
</variablelist>
<para>Use <programlisting>systemd-analyze cat-config systemd/report-geoip.conf</programlisting>
to display the full config.</para>
</refsect1>
<refsect1>
<title>See Also</title>
<para><simplelist type="inline">
<member><citerefentry><refentrytitle>systemd-report-geoip@.service</refentrytitle><manvolnum>8</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>systemd-report</refentrytitle><manvolnum>1</manvolnum></citerefentry></member>
</simplelist></para>
</refsect1>
</refentry>
+8
View File
@@ -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'],
+156
View File
@@ -0,0 +1,156 @@
<?xml version="1.0"?> <!--*-nxml-*-->
<!DOCTYPE refentry PUBLIC "-//OASIS//DTD DocBook XML V4.5//EN"
"http://www.oasis-open.org/docbook/xml/4.5/docbookx.dtd">
<!-- SPDX-License-Identifier: LGPL-2.1-or-later -->
<refentry id="systemd-report-geoip_.service" conditional="ENABLE_REPORT HAVE_LIBCURL"
xmlns:xi="http://www.w3.org/2001/XInclude">
<refentryinfo>
<title>systemd-report-geoip@.service</title>
<productname>systemd</productname>
</refentryinfo>
<refmeta>
<refentrytitle>systemd-report-geoip@.service</refentrytitle>
<manvolnum>8</manvolnum>
</refmeta>
<refnamediv>
<refname>systemd-report-geoip@.service</refname>
<refname>systemd-report-geoip.socket</refname>
<refname>systemd-report-geoip</refname>
<refpurpose>Report the approximate geographic location of the system as a metric</refpurpose>
</refnamediv>
<refsynopsisdiv>
<para><filename>systemd-report-geoip@.service</filename></para>
<para><filename>systemd-report-geoip.socket</filename></para>
<para><filename>/usr/lib/systemd/systemd-report-geoip</filename></para>
</refsynopsisdiv>
<refsect1>
<title>Description</title>
<para><command>systemd-report-geoip@.service</command> is a metrics source for
<citerefentry><refentrytitle>systemd-report</refentrytitle><manvolnum>1</manvolnum></citerefentry>. 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
<constant>io.systemd.Metrics</constant> Varlink interface on a socket linked into the
<filename>/run/systemd/report/</filename> directory.</para>
<para>The geolocation data is acquired via HTTP from the endpoint configured in
<citerefentry><refentrytitle>report-geoip.conf</refentrytitle><manvolnum>5</manvolnum></citerefentry>. The
acquired data is cached, and only refreshed once the cache is older than the configured
<varname>RefreshSec=</varname> 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.</para>
</refsect1>
<refsect1>
<title>Metrics</title>
<para>The following metrics are reported, all in the <literal>io.systemd.GeoIP.</literal> namespace. Fields
the geolocation service does not provide are not reported.</para>
<variablelist>
<varlistentry>
<term><constant>io.systemd.GeoIP.PublicAddress</constant></term>
<listitem><para>The public IP address of the system, as seen by the geolocation service. The address
is validated and reported in normalized form.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.ASN</constant></term>
<term><constant>io.systemd.GeoIP.ASName</constant></term>
<term><constant>io.systemd.GeoIP.ASDomain</constant></term>
<listitem><para>The Autonomous System Number (e.g. <literal>AS3320</literal>) of the network the public
IP address belongs to, and the name and domain of the organization operating it.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.ContinentCode</constant></term>
<term><constant>io.systemd.GeoIP.ContinentName</constant></term>
<listitem><para>The code (e.g. <literal>EU</literal>) and the name of the continent the system is
located in.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.CountryCode</constant></term>
<term><constant>io.systemd.GeoIP.CountryName</constant></term>
<listitem><para>The ISO 3166-1 alpha-2 code and the name of the country the system is located
in.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.RegionCode</constant></term>
<term><constant>io.systemd.GeoIP.RegionName</constant></term>
<listitem><para>The code and name of the region (state, province, …) the system is located
in.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.City</constant></term>
<term><constant>io.systemd.GeoIP.ZipCode</constant></term>
<listitem><para>The city and postal code of the area the system is located in.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.Timezone</constant></term>
<listitem><para>The IANA time zone name of the area the system is located in.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.Latitude</constant></term>
<term><constant>io.systemd.GeoIP.Longitude</constant></term>
<listitem><para>The geographic coordinates of the system's location, in decimal degrees.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><constant>io.systemd.GeoIP.Timestamp</constant></term>
<listitem><para>The time at which the reported data was acquired from the geolocation service, in
microseconds since the UNIX epoch (<constant>CLOCK_REALTIME</constant>). When the data is served from
the cache this is the time the cache was last refreshed, not the time of the report.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
</variablelist>
</refsect1>
<refsect1>
<title>Privacy</title>
<para>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.</para>
</refsect1>
<!-- Note: we do not document the command line switches here. The systemd-report-geoip binary itself should
not be invoked by users, but only as a service. Use systemd-report(1) to acquire metrics. -->
<refsect1>
<title>See Also</title>
<para><simplelist type="inline">
<member><citerefentry><refentrytitle>systemd</refentrytitle><manvolnum>1</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>systemd-report</refentrytitle><manvolnum>1</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>report-geoip.conf</refentrytitle><manvolnum>5</manvolnum></citerefentry></member>
</simplelist></para>
</refsect1>
</refentry>
+4
View File
@@ -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,
+4
View File
@@ -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/')
+1 -1
View File
@@ -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'
+24
View File
@@ -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",
+1
View File
@@ -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. */
+18
View File
@@ -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)
+175
View File
@@ -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);
+686
View File
@@ -0,0 +1,686 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <math.h>
#include <sys/stat.h>
#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);
}
+21
View File
@@ -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
+10
View File
@@ -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);
@@ -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)
+125
View File
@@ -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 <<EOF
[GeoIP]
Endpoint=http://localhost:8092/json/
RefreshSec=1h
EOF
rm -f /var/lib/systemd/report-geoip/cache.json
systemctl start systemd-report-geoip.socket
varlinkctl info /run/systemd/report/io.systemd.GeoIP
varlinkctl list-methods /run/systemd/report/io.systemd.GeoIP
geoip_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.GeoIP io.systemd.Metrics.List {})"
geoip_value() { echo "$geoip_metrics" | jq --seq -r "select(.name == \"$1\") | .value | tostring"; }
# Numbers are emitted in exponent notation with full precision, and jq preserves (and compares) the
# literal, so compare them numerically with a tolerance rather than textually.
geoip_number_is() { echo "$geoip_metrics" | jq --seq -r "select(.name == \"$1\") | (.value - $2 | fabs) < 1e-9 | tostring" | grep -wx true >/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 <<EOF
[GeoIP]
Endpoint=http://localhost:8092/json/
RefreshSec=0
EOF
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.City)" = "Berlin" ]
# An endpoint returning something that isn't an IP address: the address must
# be skipped, but the other fields still be reported.
systemd-run -p Type=notify --unit=fake-geoip-server-bogus \
"$FAKE_SERVER" --port=8091 --geoip-address="not an address"
cat >/run/systemd/report-geoip.conf.d/99-test.conf <<EOF
[GeoIP]
Endpoint=http://localhost:8091/json/
RefreshSec=0
EOF
geoip_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.GeoIP io.systemd.Metrics.List {})"
[ -z "$(geoip_value io.systemd.GeoIP.PublicAddress)" ]
[ "$(geoip_value io.systemd.GeoIP.City)" = "Berlin" ]
systemctl stop fake-geoip-server-bogus
# An unreachable endpoint and no cache: nothing is reported, which the metrics helpers signal with
# the NoSuchMetric sentinel error (as any source without metrics does).
rm -f /var/lib/systemd/report-geoip/cache.json
(! varlinkctl call --more /run/systemd/report/io.systemd.GeoIP io.systemd.Metrics.List {} 2>&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
+8
View File
@@ -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'],
+29
View File
@@ -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
+42
View File
@@ -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