/* * This file is part of steamos-devkit * SPDX-License-Identifier: LGPL-2.1+ * * Copyright © 2017-2018 Collabora Ltd * * This package 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. * * This package is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this package. If not, see * <http://www.gnu.org/licenses/>. */ #include "avahi-service.h" #include <avahi-client/client.h> #include <avahi-client/publish.h> #include <avahi-common/alternative.h> #include <avahi-common/error.h> #include <avahi-common/malloc.h> #include <avahi-common/simple-watch.h> #include <avahi-common/timeval.h> #include <avahi-glib/glib-malloc.h> #include <avahi-glib/glib-watch.h> #include <errno.h> #include <gio/gio.h> #include <glib/gstdio.h> #include <string.h> #include <unistd.h> #include "utils.h" #include "defines.h" struct _DkdAvahiService { GObject parent_instance; AvahiEntryGroup *group; gchar *machine_name; char *service_name; uint64_t service_port; AvahiClient *client; AvahiGLibPoll *glib_poll; gchar *login; gchar *settings; /* devkit1= prefix followed by shell-escaped arguments */ gchar *devkit1_txt; GMainLoop *loop; GKeyFile *state; const gchar *state_dir; gchar *state_subdir; gchar *state_file; }; struct _DkdAvahiServiceClass { GObjectClass parent_class; }; G_DEFINE_TYPE (DkdAvahiService, dkd_avahi_service, G_TYPE_OBJECT) /* Format version number for TXT record. Increment if we make an * incompatible change that would cause current clients to parse it * incorrectly (hopefully we will never need this). See * https://tools.ietf.org/html/rfc6763#section-6.7 */ #define CURRENT_TXTVERS "txtvers=1" #define COLLISION_MAX_TRY 3 #define STATE_GROUP "State" #define STATE_KEY_MACHINE_NAME "MachineName" #define STATE_KEY_SERVICE_NAME "ServiceName" static gboolean dkd_avahi_service_create_services (DkdAvahiService *self, AvahiClient *c, GError **error); static void dkd_avahi_service_load_state (DkdAvahiService *self) { GError *error = NULL; if (self->state == NULL) { self->state = g_key_file_new (); /* Use /var/lib if we're root, or ~/.local/share otherwise */ if (getuid () == 0) self->state_dir = DEVKIT_LOCALSTATEDIR "/lib"; else self->state_dir = g_get_user_data_dir (); self->state_subdir = g_build_filename (self->state_dir, PACKAGE, NULL); self->state_file = g_build_filename (self->state_subdir, "state.ini", NULL); } /* Make a best-effort attempt to load state, but mostly ignore any errors: * a missing or malformed state file is equivalent to no state having * been saved at all */ if (!g_key_file_load_from_file (self->state, self->state_file, G_KEY_FILE_NONE, &error)) { g_debug ("Unable to load \"%s\": %s", self->state_file, error->message); g_clear_error (&error); } } static void dkd_avahi_service_save_state (DkdAvahiService *self) { GError *error = NULL; gchar **groups; gsize n_groups = 0; g_return_if_fail (self->state != NULL); groups = g_key_file_get_groups (self->state, &n_groups); if (n_groups == 0) { /* There is no state, so delete the file instead of creating it */ g_debug ("No state to save: deleting \"%s\"", self->state_file); if (g_unlink (self->state_file) < 0 && errno != ENOENT) g_warning ("Unable to delete \"%s\": %s", self->state_file, g_strerror (errno)); if (g_rmdir (self->state_subdir) < 0 && errno != ENOENT && errno != ENOTEMPTY) g_warning ("Unable to delete \"%s\": %s", self->state_subdir, g_strerror (errno)); return; } g_strfreev (groups); g_debug ("Saving state to \"%s\"", self->state_file); if (g_mkdir_with_parents (self->state_subdir, 0700) < 0 && errno != ENOENT) { g_warning ("Unable to create \"%s\": %s", self->state_subdir, g_strerror (errno)); return; } if (!g_key_file_save_to_file (self->state, self->state_file, &error)) { g_warning ("%s", error->message); g_clear_error (&error); } } static void dkd_avahi_service_switch_to_alternative (DkdAvahiService *self) { char *n; n = avahi_alternative_service_name (self->service_name); avahi_free (self->service_name); self->service_name = n; g_debug ("Service name collision, renaming service to '%s'", self->service_name); /* Try to save the name we intended to use, and the name we actually * used, so that we'll use the same fallback in future as mandated by * https://tools.ietf.org/html/rfc6762#section-9, * https://tools.ietf.org/html/rfc6763#appendix-D */ dkd_avahi_service_load_state (self); g_key_file_set_string (self->state, STATE_GROUP, STATE_KEY_MACHINE_NAME, self->machine_name); g_key_file_set_string (self->state, STATE_GROUP, STATE_KEY_SERVICE_NAME, self->service_name); dkd_avahi_service_save_state (self); } static void entry_group_callback (AvahiEntryGroup * g, AvahiEntryGroupState state, gpointer userdata) { DkdAvahiService *self = DKD_AVAHI_SERVICE (userdata); GError *error = NULL; assert (g == self->group || self->group == NULL); self->group = g; switch (state) { case AVAHI_ENTRY_GROUP_ESTABLISHED: g_debug ("Service '%s' successfully established.", self->service_name); break; case AVAHI_ENTRY_GROUP_COLLISION: { dkd_avahi_service_switch_to_alternative (self); if (!dkd_avahi_service_create_services (self, self->client, &error)) { g_warning ("%s", error->message); g_main_loop_quit (self->loop); } break; } case AVAHI_ENTRY_GROUP_FAILURE: g_debug ("Entry group failure: %s", avahi_strerror (avahi_client_errno (self->client))); g_main_loop_quit (self->loop); break; case AVAHI_ENTRY_GROUP_UNCOMMITED: case AVAHI_ENTRY_GROUP_REGISTERING: default: ; } } static gboolean dkd_avahi_service_create_services (DkdAvahiService *self, AvahiClient *c, GError **error) { g_return_val_if_fail (DKD_IS_AVAHI_SERVICE (self), FALSE); g_return_val_if_fail (c != NULL, FALSE); if (!self->group) { if (!(self->group = avahi_entry_group_new (c, entry_group_callback, self))) { g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "avahi_entry_group_new() failed: %s", avahi_strerror (avahi_client_errno (c))); goto fail; } } if (avahi_entry_group_is_empty (self->group)) { gchar *login_pair; gchar *settings_pair; int try; g_debug ("Adding service '%s'", self->service_name); login_pair = g_strdup_printf ("login=%s", self->login); settings_pair = g_strdup_printf ("settings=%s", self->settings); for (try = 0; try < COLLISION_MAX_TRY; try++) { int ret; if ((ret = avahi_entry_group_add_service (self->group, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, 0, self->service_name, "_steamos-devkit._tcp", NULL, NULL, self->service_port, CURRENT_TXTVERS, login_pair, self->devkit1_txt, settings_pair, NULL)) < 0) { if (ret == AVAHI_ERR_COLLISION) { dkd_avahi_service_switch_to_alternative (self); continue; } g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Failed to add _steamos-devkit._tcp service: %s", avahi_strerror (ret)); goto fail; } if ((ret = avahi_entry_group_commit (self->group)) < 0) { g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Failed to commit entry group: %s", avahi_strerror (ret)); goto fail; } break; } g_free (login_pair); g_free (settings_pair); if (try >= COLLISION_MAX_TRY) { g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Too many name collisions"); goto fail; } } return TRUE; fail: return FALSE; } static void client_state_changed_cb (AvahiClient * c, AvahiClientState state, void *userdata) { DkdAvahiService *self = DKD_AVAHI_SERVICE (userdata); GError *error = NULL; assert (c); assert (c == self->client || self->client == NULL); switch (state) { case AVAHI_CLIENT_S_RUNNING: if (!dkd_avahi_service_create_services (self, c, &error)) { g_warning ("%s", error->message); g_main_loop_quit (self->loop); } break; case AVAHI_CLIENT_FAILURE: g_warning ("Client failure: %s", avahi_strerror (avahi_client_errno (c))); g_main_loop_quit (self->loop); break; case AVAHI_CLIENT_S_COLLISION: case AVAHI_CLIENT_S_REGISTERING: if (self->group) avahi_entry_group_reset (self->group); break; case AVAHI_CLIENT_CONNECTING: default: ; } } gboolean dkd_avahi_service_start (DkdAvahiService *self, const gchar *machine_name, uint64_t server_port, const gchar *entry_point_user, const gchar * const *devkit1_argv, const gchar *settings_json, GMainLoop *loop, GError **error) { int err = 0; const AvahiPoll *poll_api = NULL; if (!dkd_avahi_service_reconfigure (self, machine_name, server_port, entry_point_user, devkit1_argv, settings_json, error)) return FALSE; poll_api = avahi_glib_poll_get (self->glib_poll); self->client = avahi_client_new (poll_api, 0, client_state_changed_cb, self, &err); self->loop = g_main_loop_ref (loop); if (self->client == NULL) { g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Error initializing Avahi: %s", avahi_strerror (err)); return FALSE; } return TRUE; } gboolean dkd_avahi_service_reconfigure (DkdAvahiService *self, const gchar *machine_name, uint64_t server_port, const gchar *entry_point_user, const gchar * const *devkit1_argv, const gchar *settings_json, GError **error) { gboolean changed_name = FALSE; gboolean changed_txt = FALSE; GString *buffer; if (self->machine_name == NULL || strcmp (self->machine_name, machine_name) != 0) { gchar *temp; g_free (self->machine_name); self->machine_name = g_strdup (machine_name); temp = dk_sanitize_machine_name (machine_name); avahi_free (self->service_name); self->service_name = avahi_strdup (temp); g_free (temp); g_debug ("Machine name \"%s\" -> service name \"%s\"", self->machine_name, self->service_name); /* If the machine name matches what we were trying to use last * time, substitute the alternative that we used last time, if * any. This means that once we've renamed a machine from * "Foocorp GameMachine X100" to "Foocorp GameMachine X100 #3", * we'll keep that name indefinitely, meaning we don't need to * put serial numbers in machine names to get a reasonable UX. * See https://tools.ietf.org/html/rfc6762#section-9, * https://tools.ietf.org/html/rfc6763#appendix-D * * This behaviour also means that if we change how * dk_sanitize_machine_name() works, existing machines will keep * their current names until reconfigured, so client state * won't be invalidated. */ dkd_avahi_service_load_state (self); temp = g_key_file_get_string (self->state, STATE_GROUP, STATE_KEY_MACHINE_NAME, NULL); if (temp != NULL && strcmp (temp, machine_name) == 0) { g_free (temp); temp = g_key_file_get_string (self->state, STATE_GROUP, STATE_KEY_SERVICE_NAME, NULL); if (temp != NULL && strcmp (temp, self->service_name) != 0) { g_debug ("Using stored service name \"%s\" instead", temp); avahi_free (self->service_name); self->service_name = avahi_strdup (temp); } } else if (strcmp (machine_name, self->service_name) == 0) { /* In the common case where the machine name is the same as * the service name, don't bother storing either. */ g_debug ("Machine name matches service name: removing " "saved collision state"); g_key_file_remove_group (self->state, STATE_GROUP, NULL); dkd_avahi_service_save_state (self); } else { /* Store the new machine name and the resulting service name */ g_debug ("Saving remapped name"); g_key_file_set_string (self->state, STATE_GROUP, STATE_KEY_MACHINE_NAME, self->machine_name); g_key_file_set_string (self->state, STATE_GROUP, STATE_KEY_SERVICE_NAME, self->service_name); dkd_avahi_service_save_state (self); } g_free (temp); changed_name = TRUE; } if (self->login == NULL || strcmp (self->login, entry_point_user) != 0) { g_free (self->login); self->login = g_strdup (entry_point_user); changed_txt = TRUE; } if (self->settings == NULL || strcmp (self->settings, settings_json) != 0) { g_free (self->settings); self->settings = g_strdup (settings_json); changed_txt = TRUE; } if (self->service_port != server_port) { self->service_port = server_port; } buffer = g_string_new ("devkit1="); if (devkit1_argv == NULL || devkit1_argv[0] == NULL) { g_string_append (buffer, "devkit-1"); } else { const gchar *const *iter; for (iter = devkit1_argv; *iter != NULL; iter++) { gchar *tmp = g_shell_quote (*iter); if (buffer->len > strlen ("devkit1=")) g_string_append_c (buffer, ' '); g_string_append (buffer, tmp); g_free (tmp); } } if (self->devkit1_txt == NULL || strcmp (self->devkit1_txt, buffer->str) != 0) { g_free (self->devkit1_txt); self->devkit1_txt = g_string_free (buffer, FALSE); changed_txt = TRUE; } else { g_string_free (buffer, TRUE); } if (self->client != NULL && avahi_client_get_state (self->client) == AVAHI_CLIENT_S_RUNNING) { if (changed_name || (changed_txt && self->group == NULL)) { if (self->group) avahi_entry_group_reset (self->group); if (!dkd_avahi_service_create_services (self, self->client, error)) { g_prefix_error (error, "Unable to publish new service name: "); return FALSE; } } else if (changed_txt) { int code; gchar *login_pair; gchar *settings_pair; login_pair = g_strdup_printf ("login=%s", self->login); settings_pair = g_strdup_printf ("settings=%s", self->settings); code = avahi_entry_group_update_service_txt (self->group, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, 0, self->service_name, "_steamos-devkit._tcp", NULL, CURRENT_TXTVERS, login_pair, self->devkit1_txt, settings_pair, NULL); g_free (login_pair); g_free (settings_pair); if (code < 0) { g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Failed to update DNS-SD TXT record: %s", avahi_strerror (code)); return FALSE; } } } return TRUE; } /* For some reason avahi_entry_group_free() returns int, which means * gcc 8 doesn't like to cast it as a GDestroyNotify */ static void dkd_avahi_entry_group_free (AvahiEntryGroup *self) { avahi_entry_group_free (self); } void dkd_avahi_service_stop (DkdAvahiService *self) { g_clear_pointer (&self->group, dkd_avahi_entry_group_free); g_clear_pointer (&self->client, avahi_client_free); g_clear_pointer (&self->glib_poll, avahi_glib_poll_free); g_clear_pointer (&self->service_name, avahi_free); g_clear_pointer (&self->login, g_free); g_clear_pointer (&self->loop, g_main_loop_unref); } static void dkd_avahi_service_init (DkdAvahiService *self) { self->service_name = NULL; self->service_port = SERVICE_PORT; self->glib_poll = avahi_glib_poll_new (NULL, G_PRIORITY_DEFAULT); self->client = NULL; } static void dkd_avahi_service_finalize (GObject *object) { DkdAvahiService *self = DKD_AVAHI_SERVICE (object); dkd_avahi_service_stop (self); g_clear_pointer (&self->state, g_key_file_unref); g_clear_pointer (&self->machine_name, g_free); g_clear_pointer (&self->state_subdir, g_free); g_clear_pointer (&self->state_file, g_free); G_OBJECT_CLASS (dkd_avahi_service_parent_class)->finalize (object); } static void dkd_avahi_service_class_init (DkdAvahiServiceClass *cls) { GObjectClass *object_class = G_OBJECT_CLASS (cls); avahi_set_allocator (avahi_glib_allocator ()); object_class->finalize = dkd_avahi_service_finalize; } DkdAvahiService * dkd_avahi_service_new (void) { return g_object_new (DKD_TYPE_AVAHI_SERVICE, NULL); }