diff --git a/src/avahi-service.c b/src/avahi-service.c new file mode 100644 index 0000000000000000000000000000000000000000..a3df9078e09e36ec4984c37664beee4914a03102 --- /dev/null +++ b/src/avahi-service.c @@ -0,0 +1,619 @@ +/* + * 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); +} diff --git a/src/avahi-service.h b/src/avahi-service.h new file mode 100644 index 0000000000000000000000000000000000000000..be52b841d88cb4557f4cc67390a913a2076b843b --- /dev/null +++ b/src/avahi-service.h @@ -0,0 +1,61 @@ +/* + * This file is part of steamos-devkit + * SPDX-License-Identifier: LGPL-2.1+ + * + * Copyright © 2017 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/>. + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <stdint.h> + +typedef struct _DkdAvahiService DkdAvahiService; +typedef struct _DkdAvahiServiceClass DkdAvahiServiceClass; + +#define DKD_TYPE_AVAHI_SERVICE (dkd_avahi_service_get_type ()) +#define DKD_AVAHI_SERVICE(obj) \ + G_TYPE_CHECK_INSTANCE_CAST ((obj), DKD_TYPE_AVAHI_SERVICE, DkdAvahiService) +#define DKD_AVAHI_SERVICE_CLASS(cls) \ + G_TYPE_CHECK_CLASS_CAST ((cls), DKD_TYPE_AVAHI_SERVICE, DkdAvahiServiceClass) +#define DKD_IS_AVAHI_SERVICE(obj) \ + G_TYPE_CHECK_INSTANCE_TYPE ((obj), DKD_TYPE_AVAHI_SERVICE) +#define DKD_IS_AVAHI_SERVICE_CLASS(cls) \ + G_TYPE_CHECK_CLASS_TYPE ((cls), DKD_TYPE_AVAHI_SERVICE) +#define DKD_AVAHI_SERVICE_GET_CLASS(obj) \ + G_TYPE_INSTANCE_GET_CLASS ((obj), DKD_TYPE_AVAHI_SERVICE, DkdAvahiServiceClass) + +DkdAvahiService *dkd_avahi_service_new (void); +GType dkd_avahi_service_get_type (void); + +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); +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); +void dkd_avahi_service_stop (DkdAvahiService *self); diff --git a/src/defines.h b/src/defines.h new file mode 100644 index 0000000000000000000000000000000000000000..eb0690ef3026cfcfe994664ed1d6ddbcb115bb02 --- /dev/null +++ b/src/defines.h @@ -0,0 +1,6 @@ +// remnants from the auto build system +#define PACKAGE "steamos-devkit-service" +#define DEVKIT_DATADIR "/usr/share" +#define SERVICE_PORT 32000 +#define DEVKIT_HOOKS_DIR "/usr/share/steamos-devkit/hooks" +#define DEVKIT_LOCALSTATEDIR "/var" diff --git a/src/json.c b/src/json.c new file mode 100644 index 0000000000000000000000000000000000000000..ade654bd6a2e70b1f996171cab91c46927d92017 --- /dev/null +++ b/src/json.c @@ -0,0 +1,66 @@ +/* + * This file is part of steamos-devkit + * SPDX-License-Identifier: LGPL-2.1+ + * + * Copyright © 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 "json.h" +#include "defines.h" + +gboolean +dk_json_parser_load_from_bytes (JsonParser *parser, + GBytes *bytes, + GError **error) +{ + gconstpointer data = NULL; + gsize len = 0; + + g_return_val_if_fail (JSON_IS_PARSER (parser), FALSE); + g_return_val_if_fail (bytes != NULL, FALSE); + g_return_val_if_fail (error == NULL || *error == NULL, FALSE); + + data = g_bytes_get_data (bytes, &len); + + if (data == NULL) + { + /* JsonParser doesn't like NULL data, even if its length is 0. + * g_bytes_get_data() documents a guarantee that it will only + * return NULL if the length is 0. */ + g_assert (len == 0); + data = ""; + } + + return json_parser_load_from_data (parser, data, len, error); +} + +#if !JSON_CHECK_VERSION(1, 2, 0) +gchar * +dk_json_to_string (JsonNode *node, + gboolean pretty) +{ + JsonGenerator *gen; + gchar *ret; + + gen = json_generator_new (); + json_generator_set_root (gen, node); /* a copy */ + json_generator_set_pretty (gen, pretty); + ret = json_generator_to_data (gen, NULL); + g_object_unref (gen); + return ret; +} +#endif diff --git a/src/json.h b/src/json.h new file mode 100644 index 0000000000000000000000000000000000000000..cafa0e9c963b271a7211910f3c7e686d023e26a0 --- /dev/null +++ b/src/json.h @@ -0,0 +1,34 @@ +/* + * This file is part of steamos-devkit + * SPDX-License-Identifier: LGPL-2.1+ + * + * Copyright © 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/>. + */ + +#pragma once + +#include <json-glib/json-glib.h> + +gboolean dk_json_parser_load_from_bytes (JsonParser *parser, + GBytes *bytes, + GError **error); + +#if JSON_CHECK_VERSION(1, 2, 0) +#define dk_json_to_string(node, pretty) json_to_string (node, pretty) +#else +gchar *dk_json_to_string (JsonNode *node, gboolean pretty); +#endif diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000000000000000000000000000000000000..dc1787abe2fcfacd07f164a4bcebe70091b39548 --- /dev/null +++ b/src/main.c @@ -0,0 +1,1242 @@ +/* + * 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 <errno.h> +#include <gio/gio.h> +#include <gio/giotypes.h> +#include <gio/gunixoutputstream.h> +#include <glib-unix.h> +#include <glib.h> +#include <glib/gprintf.h> +#include <glib/gstdio.h> +#include <json-glib/json-glib.h> +#include <json-glib/json-gobject.h> +#include <libsoup/soup.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <systemd/sd-daemon.h> +#include <unistd.h> + +#include "avahi-service.h" +// #include "lib/backports.h" +#include "json.h" +#include "utils.h" + +#include "defines.h" + +/* https://www.freedesktop.org/wiki/Software/systemd/hostnamed/ */ +#define HOSTNAME_BUS_NAME "org.freedesktop.hostname1" +#define HOSTNAME_OBJECT_PATH "/org/freedesktop/hostname1" +#define HOSTNAME_IFACE HOSTNAME_BUS_NAME +#define PROPERTIES_IFACE "org.freedesktop.DBus.Properties" + +typedef struct +{ + GObject parent_instance; + + GKeyFile *config; + GKeyFile *user_config; + gchar *machine_name; + gchar **users; + const gchar *entry_point_user; + + GMainLoop *loop; + SoupServer *server; + uint64_t server_port; + DkdAvahiService *avahi_service; + GDBusConnection *system_bus; + guint hup_signal_watch; + guint hostname_property_changed_subscription; + gchar **devkit1_argv; + gchar *settings; + + struct + { + gchar **hooks; + gchar *entry_point; + gboolean close_stdout_when_ready; + gboolean use_default_hooks; + } options; +} DkdApplication; + +typedef struct +{ + GObjectClass parent_class; +} DkdApplicationClass; + +#define DKD_TYPE_APPLICATION (dkd_application_get_type ()) +#define DKD_APPLICATION(obj) \ + G_TYPE_CHECK_INSTANCE_CAST ((obj), DKD_TYPE_APPLICATION, DkdApplication) +#define DKD_APPLICATION_CLASS(cls) \ + G_TYPE_CHECK_CLASS_CAST ((cls), DKD_TYPE_APPLICATION, DkdApplicationClass) +#define DKD_IS_APPLICATION(obj) \ + G_TYPE_CHECK_INSTANCE_TYPE ((obj), DKD_TYPE_APPLICATION) +#define DKD_IS_APPLICATION_CLASS(cls) \ + G_TYPE_CHECK_CLASS_TYPE ((cls), DKD_TYPE_APPLICATION) +#define DKD_APPLICATION_GET_CLASS(obj) \ + G_TYPE_INSTANCE_GET_CLASS ((obj), DKD_TYPE_APPLICATION, DkdApplicationClass) + +static GType dkd_application_get_type (void); + +G_DEFINE_TYPE (DkdApplication, dkd_application, G_TYPE_OBJECT) + +typedef struct +{ + GTask *task; + DkdApplication *app; + SoupMessage *msg; + gchar *reply; + guint status; + gchar *sender_ip; +} AsyncQuery; + +static AsyncQuery * +async_query_new (DkdApplication *app, + SoupMessage *m) +{ + AsyncQuery *aq = g_new0 (AsyncQuery, 1); + aq->app = g_object_ref (app); + aq->msg = g_object_ref (m); + aq->status = SOUP_STATUS_INTERNAL_SERVER_ERROR; + return aq; +} + +static void +async_query_free (AsyncQuery * aq) +{ + if (aq) + { + g_clear_object (&aq->task); + g_clear_object (&aq->msg); + g_clear_object (&aq->app); + g_clear_pointer (&aq->reply, g_free); + g_clear_pointer (&aq->sender_ip, g_free); + g_free (aq); + } +} + +/* Runs in worker thread */ +static gboolean +writefile (gchar * template, const guint8 * data, gsize len, gboolean newline) +{ + GOutputStream *ostream; + GError *error = NULL; + int fd; + gboolean ret = FALSE; + + /* Create an unique file and write data */ + fd = mkstemp (template); + if (fd >= 0) + { + ostream = g_unix_output_stream_new (fd, FALSE); + /* Write data to the file */ + if (!g_output_stream_write_all (ostream, data, len, NULL, NULL, &error)) + { + g_warning ("Failed to write %s: %s", template, error->message); + g_clear_error (&error); + } + else + { + /* Add a new line if necessary */ + if (newline) + { + if (!g_output_stream_write_all + (ostream, "\n", 1, NULL, NULL, &error)) + { + g_warning ("Failed to write new line to %s: %s", template, + error->message); + g_clear_error (&error); + } + else + ret = TRUE; + } + else + ret = TRUE; + } + if (!g_output_stream_close (ostream, NULL, &error)) + { + g_warning ("Failed to close %s: %s", template, error->message); + g_clear_error (&error); + ret = FALSE; + } + g_clear_object (&ostream); + close (fd); + } + return ret; +} + +/* Runs in worker thread */ +static gchar * +writekey (SoupMessage * msg) +{ + gchar *filename = NULL; + const guint8 *data = (const guint8 *) msg->request_body->data; + gsize len = msg->request_body->length; + char template[] = "/tmp/devkitd-XXXXXX"; + gboolean found_name = FALSE; + + if (data) + { + gsize idx; + + /* Check length */ + if (len >= (64 * 1024)) + goto out; + /* Check data start with 'ssh-rsa ' */ + if (memcmp ("ssh-rsa ", data, 8) != 0) + goto out; + /* Count character until Base 64 data */ + for (idx = 8; idx < len; idx++) + { + if (data[idx] != ' ') + break; + } + /* Check Base64 data */ + for (; idx < len; idx++) + { + if ((data[idx] == '+') || + (data[idx] >= '/' && data[idx] <= '9') || + (data[idx] >= 'a' && data[idx] <= 'z') || + (data[idx] >= 'A' && data[idx] <= 'Z')) + continue; + else if (data[idx] == '=') + { + idx++; + if ((idx < len) && (data[idx] == ' ')) + break; + else if ((idx < len) && (data[idx] == '=')) + { + idx++; + if ((idx < len) && (data[idx] == ' ')) + break; + } + goto out; + } + else if (data[idx] == ' ') + break; + else + goto out; + } + + for (; idx < len; idx++) + { + if (data[idx] == ' ') + { + /* it's a space, the rest is name or magic phrase, don't write to disk */ + if (found_name) + { + len = idx; + } + else + { + found_name = TRUE; + } + } + if (data[idx] == '\0') + goto out; + if (data[idx] == '\n' && idx != len - 1) + goto out; + } + /* write data to the file */ + if (writefile + (template, data, len, ((data[len - 1] != '\n') ? TRUE : FALSE))) + filename = g_strdup (template); + else + unlink (template); + } +out: + return filename; +} + +/* Runs in worker thread */ +static void +deletekey (const gchar * filename) +{ + if (filename) + unlink (filename); +} + +/* Runs in worker thread */ +static gboolean +exec_script (DkdApplication *self, + const gchar *action, + const gchar *keypath, + const gchar *sender_ip, + const gchar * const *users, + gchar **script_output) +{ + GSubprocessLauncher *launcher = NULL; + GSubprocess *subprocess = NULL; + gchar *script = NULL; + gboolean ret = FALSE; + GError *error = NULL; + GPtrArray *argv = NULL; + guint i; + GBytes *local_bytes = NULL; + gchar *local_utf8 = NULL; + + g_return_val_if_fail (action != NULL, FALSE); + g_return_val_if_fail (keypath != NULL, FALSE); + + script = dk_find_hook ((const gchar * const *) self->options.hooks, + self->options.use_default_hooks, + action, &error); + + if (!script) + { + g_warning ("%s", error->message); + g_clear_error (&error); + return FALSE; + } + + argv = g_ptr_array_new_with_free_func (g_free); + launcher = g_subprocess_launcher_new (G_SUBPROCESS_FLAGS_STDOUT_PIPE | G_SUBPROCESS_FLAGS_STDERR_MERGE); + + if (dk_hook_is_generic_python (script)) + g_ptr_array_add (argv, g_strdup (dk_get_best_python ())); + + g_ptr_array_add (argv, g_strdup (script)); + g_ptr_array_add (argv, g_strdup (keypath)); + + if (sender_ip != NULL) + g_ptr_array_add(argv, g_strdup(sender_ip)); + + for (i = 0; users != NULL && users[i] != NULL; i++) + g_ptr_array_add (argv, g_strdup (users[i])); + + g_ptr_array_add (argv, NULL); + + subprocess = g_subprocess_launcher_spawnv ( + launcher, + (const gchar * const *) argv->pdata, + &error); + + if (!subprocess) + { + GString *command_line = g_string_new (""); + + for (i = 0; i < argv->len; i++) + { + if (argv->pdata[i] != NULL) + { + gchar *quoted = g_shell_quote (argv->pdata[i]); + + if (command_line->len > 0) + g_string_append_c (command_line, ' '); + + g_string_append (command_line, quoted); + g_free (quoted); + } + } + + g_warning ("Failed to create subprocess: %s: %s", + command_line->str, error->message); + g_clear_error (&error); + g_string_free (command_line, TRUE); + goto out; + } + if (!g_subprocess_communicate (subprocess, NULL, NULL, &local_bytes, NULL, &error)) + { + g_warning ("Fail to wait subprocess : %s", error->message); + g_clear_error (&error); + goto out; + } + + local_utf8 = g_utf8_make_valid (g_bytes_get_data (local_bytes, NULL), -1); + g_message ("Subprocess output: %s", local_utf8); + + if (!g_subprocess_get_if_exited (subprocess)) + { + g_warning ("Subprocess exit abnormally"); + goto out; + } + else + { + gint exit_status = g_subprocess_get_exit_status (subprocess); + if (exit_status == 0) + { + ret = TRUE; + } + } +out: + if (script_output != NULL) + *script_output = g_steal_pointer (&local_utf8); + else + g_clear_pointer (&local_utf8, g_free); + g_bytes_unref (local_bytes); + if (subprocess) + g_object_unref (subprocess); + g_clear_object (&launcher); + g_free (script); + if (argv) + g_ptr_array_unref (argv); + return ret; +} + +/* Runs in worker thread */ +static void +add_ssh_key_cb (GTask * task, gpointer source_object, gpointer task_data, + GCancellable * cancellable) +{ + AsyncQuery *aq = task_data; + gchar *keypath; + gchar *script_output = NULL; + + g_debug ("add_ssh_key_cb runs in a separate thread"); + + keypath = writekey (aq->msg); + if (!keypath) + { + aq->status = SOUP_STATUS_FORBIDDEN; + goto out; + } + + if (!exec_script (aq->app, "approve-ssh-key", keypath, aq->sender_ip, + NULL, + &script_output)) + { + aq->status = SOUP_STATUS_FORBIDDEN; + aq->reply = g_strconcat ("approve-ssh-key:\n", script_output, NULL); + g_clear_pointer (&script_output, g_free); + goto out; + } + + aq->reply = g_strconcat ("approve-ssh-key:\n", script_output, NULL); + g_clear_pointer (&script_output, g_free); + + if (exec_script (aq->app, "install-ssh-key", keypath, NULL, + (const gchar * const *) aq->app->users, + &script_output)) + { + aq->status = SOUP_STATUS_OK; + } + else + { + aq->status = SOUP_STATUS_INTERNAL_SERVER_ERROR; + } + aq->reply = g_strconcat (aq->reply, "install-ssh-key:\n", script_output, NULL); + g_clear_pointer (&script_output, g_free); + +out: + deletekey (keypath); +} + +static void +add_ssh_key_completed (GObject * source_object, GAsyncResult * res, + gpointer user_data) +{ + AsyncQuery *aq = user_data; + + if (aq->reply) + soup_message_set_response (aq->msg, "text/plain", SOUP_MEMORY_TAKE, + aq->reply, strlen (aq->reply)); + aq->reply = NULL; /* ownership transferred, do not free */ + soup_message_set_status (aq->msg, aq->status); + soup_server_unpause_message (aq->app->server, aq->msg); + + async_query_free (aq); + g_debug ("Task completed"); +} + +static void +server_callback (SoupServer * server, SoupMessage * msg, + const char *path, GHashTable * query, + SoupClientContext * context, gpointer data) +{ + DkdApplication *app = DKD_APPLICATION (data); + SoupMessageHeadersIter iter; + const char *name, *value; + + g_debug ("%s %s HTTP/1.%d", msg->method, path, + soup_message_get_http_version (msg)); + soup_message_headers_iter_init (&iter, msg->request_headers); + while (soup_message_headers_iter_next (&iter, &name, &value)) + g_debug ("%s: %s", name, value); + if (msg->request_body->length) + g_debug ("%s", msg->request_body->data); + + if ((msg->method == SOUP_METHOD_POST) && strcmp ("/register", path) == 0) + { + AsyncQuery *aq; + GSocketAddress *sockaddr; + GInetAddress *addr; + gchar *sender_ip = NULL; + sockaddr = soup_client_context_get_remote_address (context); + if (sockaddr != NULL && G_IS_INET_SOCKET_ADDRESS (sockaddr)) + { + addr = g_inet_socket_address_get_address ( + G_INET_SOCKET_ADDRESS (sockaddr)); + sender_ip = g_inet_address_to_string (addr); + g_debug ("From %s", sender_ip); + } + else + { + g_debug ("Unable to get request ip address"); + } + aq = async_query_new (app, msg); + aq->task = g_task_new (NULL, NULL, add_ssh_key_completed, aq); + aq->sender_ip = sender_ip; + soup_server_pause_message (server, msg); + g_task_set_task_data (aq->task, aq, NULL); + g_task_run_in_thread (aq->task, add_ssh_key_cb); + } + else if (msg->method == SOUP_METHOD_GET && strcmp ("/login-name", path) == 0) + { + soup_message_set_response (msg, "text/plain", SOUP_MEMORY_COPY, + app->entry_point_user, + strlen (app->entry_point_user)); + soup_message_set_status (msg, SOUP_STATUS_OK); + } + else if (msg->method == SOUP_METHOD_GET && + strcmp ("/properties.json", path) == 0) + { + gchar *text; + JsonNode *node; + JsonObject *object; + JsonArray *arr; + gchar **arg; + + node = json_node_new (JSON_NODE_OBJECT); + object = json_object_new (); + json_node_set_object (node, object); + json_object_set_int_member (object, "txtvers", 1); + json_object_set_string_member (object, "login", + app->entry_point_user); + json_object_set_string_member (object, "settings", + app->settings); + arr = json_array_new (); + + for (arg = app->devkit1_argv; *arg != NULL; arg++) + json_array_add_string_element (arr, *arg); + + /* This takes ownership: do not free arr now! */ + json_object_set_array_member (object, "devkit1", arr); + + text = dk_json_to_string (node, TRUE); + soup_message_set_response (msg, "application/json", + SOUP_MEMORY_TAKE, + text, strlen (text)); + soup_message_set_status (msg, SOUP_STATUS_OK); + json_object_unref (object); + json_node_free (node); + } + else if (msg->method == SOUP_METHOD_GET && query + && g_hash_table_size (query)) + { + gchar *command = g_hash_table_lookup (query, "command"); + if (command) + { + g_debug ("Received command : %s", command); + if (!strcmp ("ping", command)) + { + soup_message_set_response (msg, "text/plain", + SOUP_MEMORY_STATIC, "pong\n", 6); + soup_message_set_status (msg, SOUP_STATUS_OK); + } + else + { + soup_message_set_status (msg, SOUP_STATUS_NOT_FOUND); + } + } + } + else + { + soup_message_set_status (msg, SOUP_STATUS_NOT_FOUND); + } +} + +static void +dkd_application_init (DkdApplication *self) +{ + self->config = g_key_file_new (); + self->user_config = g_key_file_new (); + self->loop = g_main_loop_new (NULL, TRUE); + self->server = soup_server_new (SOUP_SERVER_SERVER_HEADER, + "steamos-devkit-service", NULL); + self->options.hooks = NULL; + self->options.close_stdout_when_ready = FALSE; + self->options.use_default_hooks = TRUE; +} + +#if !GLIB_CHECK_VERSION(2, 69, 0) +#define g_spawn_check_wait_status(x, e) (g_spawn_check_exit_status (x, e)) +#endif + +static gboolean +dkd_application_identify (DkdApplication *self, + GError **error) +{ + GSubprocessLauncher *launcher = NULL; + GSubprocess *subprocess = NULL; + JsonParser *parser = NULL; + JsonReader *reader = NULL; + JsonNode *root = NULL; + gchar *script = NULL; + GBytes *stdout_buf = NULL; + gboolean ret = FALSE; + const gchar *machine_name; + + script = dk_find_hook ((const gchar * const *) self->options.hooks, + self->options.use_default_hooks, + "devkit-1-identify", error); + + if (script == NULL) + goto out; + + launcher = g_subprocess_launcher_new (G_SUBPROCESS_FLAGS_STDOUT_PIPE); + + if (dk_hook_is_generic_python (script)) + subprocess = g_subprocess_launcher_spawn (launcher, error, + dk_get_best_python (), + script, NULL); + else + subprocess = g_subprocess_launcher_spawn (launcher, error, script, NULL); + + if (!subprocess) + { + g_prefix_error (error, "Failed to create subprocess '%s': ", script); + goto out; + } + + if (!g_subprocess_communicate (subprocess, NULL, NULL, &stdout_buf, + NULL, error)) + { + g_prefix_error (error, "Failed to communicate with subprocess '%s': ", + script); + goto out; + } + + if (!g_spawn_check_wait_status (g_subprocess_get_status (subprocess), + error)) + { + g_prefix_error (error, "Subprocess '%s' failed: ", script); + goto out; + } + + parser = json_parser_new (); + + if (!dk_json_parser_load_from_bytes (parser, stdout_buf, error)) + { + g_prefix_error (error, "Failed to parse output of subprocess '%s': ", + script); + goto out; + } + + root = json_parser_get_root (parser); + + if (root == NULL) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Subprocess '%s' returned empty result", script); + goto out; + } + + if (json_node_get_node_type (root) != JSON_NODE_OBJECT) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Subprocess '%s' did not return a JSON object", script); + goto out; + } + + reader = json_reader_new (root); + json_reader_read_member (reader, "machine_name"); + machine_name = json_reader_get_string_value (reader); + + if (machine_name != NULL) + { + g_free (self->machine_name); + self->machine_name = g_strdup (machine_name); + } + + json_reader_end_member (reader); + ret = TRUE; + +out: + g_clear_pointer (&stdout_buf, g_bytes_unref); + g_clear_object (&launcher); + g_clear_object (&subprocess); + g_clear_object (&parser); + g_clear_object (&reader); + g_free (script); + return ret; +} + +static gboolean +dkd_application_load_configuration (DkdApplication *self, + GError **error) +{ + gchar *user_config_path = g_build_path("/", g_get_home_dir(), ".config", PACKAGE "/" PACKAGE ".conf", NULL); + const gchar * const search_dirs[] = + { + "/etc", + DEVKIT_DATADIR, + NULL + }; + + GError *local_error = NULL; + gchar *port; + + if (!dkd_application_identify (self, error)) { + g_free (user_config_path); + return FALSE; + } + + if (self->machine_name == NULL) + { + /* Last resort: use gethostname() even if the result is something + * useless like steamos or localhost. This will only happen the + * first time. On reloads, the previous machine_name will be + * carried over if the identify script can't come up with + * anything better. */ + self->machine_name = g_strdup (g_get_host_name ()); + } + + if (!g_key_file_load_from_dirs (self->config, PACKAGE "/" PACKAGE ".conf", + (const gchar **) search_dirs, + NULL, G_KEY_FILE_NONE, &local_error)) + { + if (g_error_matches (local_error, G_KEY_FILE_ERROR, + G_KEY_FILE_ERROR_NOT_FOUND)) + { + g_clear_error (&local_error); + g_debug ("Site config file not found"); + } + else + { + g_propagate_prefixed_error (error, local_error, + "Failed to load configuration: "); + } + } + + /* Next load user config to try to override any defaults in system config */ + if (!g_key_file_load_from_file (self->user_config, user_config_path, + G_KEY_FILE_NONE, &local_error)) + { + // Ignore since user config is not required + g_clear_error (&local_error); + g_debug ("User config file not found"); + } + g_free (user_config_path); + + // Now load server_port, since we are going to do listen_all after we return + port = g_key_file_get_string (self->config, "Settings", "Port", NULL); + if (port == NULL) + { + // Use default value unless user config has one + self->server_port = SERVICE_PORT; + } + else + { + self->server_port = atol (port); + // Invalid port or atol failed. + if (self->server_port == 0) + { + self->server_port = SERVICE_PORT; + } + } + + port = g_key_file_get_string (self->user_config, "Settings", "Port", NULL); + if (port == NULL) + { + // Ignore, we set default above or have system wide setting + } + else + { + // TODO: Make sure result is valid? + self->server_port = atol (port); + } + + return TRUE; +} + +static gboolean +dkd_application_publish (DkdApplication *self, + GError **error) +{ + const gchar * const standard_users[] = { "root", "desktop", "steam" }; + GPtrArray *users = NULL; + gsize length = 0; + gchar **settings = g_key_file_get_keys (self->config, "Settings", &length, NULL); + gsize user_length = 0; + gchar **user_settings = g_key_file_get_keys (self->user_config, "Settings", &user_length, NULL); + + JsonNode *node; + JsonObject *object; + + guint s = 0; + gchar *key; + + if (length > 0) + key = settings[s]; + else + key = NULL; + + node = json_node_new (JSON_NODE_OBJECT); + object = json_object_new (); + json_node_set_object (node, object); + + while (key != NULL) { + gchar *value = g_key_file_get_string (self->config, "Settings", key, NULL); + if (value != NULL) { + json_object_set_string_member (object, key, + value); + } + key = settings[s++]; + } + + // Now override system settings with user settings if present + s = 0; + if (length > 0) + g_strfreev (settings); + + if (user_length > 0) + key = user_settings[0]; + else + key = NULL; + + while (key != NULL) { + gchar *value = g_key_file_get_string (self->user_config, "Settings", key, NULL); + if (value != NULL) { + json_object_set_string_member (object, key, value); + } + key = user_settings[s++]; + } + + self->settings = dk_json_to_string (node, FALSE); + + users = g_ptr_array_new_with_free_func (g_free); + + if (geteuid () == 0) + { + gchar **configured_users = NULL; + guint i; + + configured_users = g_key_file_get_string_list (self->config, + "Users", + "ShellUsers", + NULL, NULL); + + if (configured_users != NULL) + { + for (i = 0; configured_users[i] != NULL; i++) + g_ptr_array_add (users, g_strdup (configured_users[i])); + } + else + { + for (i = 0; i < G_N_ELEMENTS (standard_users); i++) + g_ptr_array_add (users, g_strdup (standard_users[i])); + } + + g_strfreev (configured_users); + } + else + { + gchar **configured_users = NULL; + guint i; + + configured_users = g_key_file_get_string_list (self->user_config, + "Users", + "ShellUsers", + NULL, NULL); + + if (configured_users != NULL) + { + for (i = 0; configured_users[i] != NULL; i++) + g_ptr_array_add (users, g_strdup (configured_users[i])); + } + else + { + // No user config, so just use current username + g_ptr_array_add (users, g_strdup (g_get_user_name ())); + } + } + + /* Check the length before appending the NULL that makes it into + * a GStrv */ + if (users->len == 1) + { + /* There is only one user, so we can run new-game and ensure-game + * as that user directly */ + self->entry_point_user = g_ptr_array_index (users, 0); + } + else + { + /* We need to be root to enable swapping between users */ + self->entry_point_user = "root"; + } + + g_ptr_array_add (users, NULL); + + /* "Steal" contents of GPtrArray as a gchar ** */ + self->users = (gchar **) g_ptr_array_free (users, FALSE); + + if (self->avahi_service == NULL) + { + self->avahi_service = dkd_avahi_service_new (); + + if (!dkd_avahi_service_start (self->avahi_service, + self->machine_name, + self->server_port, + self->entry_point_user, + (const gchar * const *) self->devkit1_argv, + self->settings, + self->loop, error)) + { + g_prefix_error (error, "Failed to start mDNS service: "); + return FALSE; + } + } + else if (!dkd_avahi_service_reconfigure (self->avahi_service, + self->machine_name, + self->server_port, + self->entry_point_user, + (const gchar * const *) self->devkit1_argv, + self->settings, + error)) + { + g_prefix_error (error, "Failed to reconfigure mDNS service: "); + return FALSE; + } + + return TRUE; +} + +static void +dkd_application_reconfigure (DkdApplication *self) +{ + GError *error = NULL; + + if (!dkd_application_load_configuration (self, &error)) + { + g_warning ("Could not reload configuration: %s", error->message); + g_clear_error (&error); + } + else if (!dkd_application_publish (self, &error)) + { + g_warning ("Could not apply new configuration: %s", error->message); + g_clear_error (&error); + } +} + +static gboolean +hup_signal_cb (gpointer user_data) +{ + DkdApplication *self = DKD_APPLICATION (user_data); + + dkd_application_reconfigure (self); + return G_SOURCE_CONTINUE; +} + +static void +hostname_changed_cb (GDBusConnection *system_bus G_GNUC_UNUSED, + const gchar *sender G_GNUC_UNUSED, + const gchar *object_path G_GNUC_UNUSED, + const gchar *iface G_GNUC_UNUSED, + const gchar *signal G_GNUC_UNUSED, + GVariant *parameters G_GNUC_UNUSED, + gpointer user_data) +{ + DkdApplication *self = DKD_APPLICATION (user_data); + + /* Ignore the parameters: any property change potentially causes a + * machine identity change */ + dkd_application_reconfigure (self); +} + +static void +system_bus_get_cb (GObject *source_object G_GNUC_UNUSED, + GAsyncResult *result, + gpointer user_data) +{ + DkdApplication *self = DKD_APPLICATION (user_data); + GError *error = NULL; + + self->system_bus = g_bus_get_finish (result, &error); + + if (self->system_bus != NULL) + { + self->hostname_property_changed_subscription = + g_dbus_connection_signal_subscribe ( + self->system_bus, HOSTNAME_BUS_NAME, PROPERTIES_IFACE, + "PropertiesChanged", HOSTNAME_OBJECT_PATH, HOSTNAME_IFACE, + G_DBUS_SIGNAL_FLAGS_NONE, hostname_changed_cb, self, NULL); + } + else + { + g_warning ("Unable to connect to system bus: %s", error->message); + g_clear_error (&error); + } + + g_object_unref (self); +} + +static gboolean +deprecated_option_cb (const char *option, + const char *value, + gpointer option_group_data, + GError **error) +{ + g_printerr ("steamos-devkit-service: Ignoring deprecated option \"%s\"\n", + option); + return TRUE; +} + +static gboolean +dkd_application_start (DkdApplication *self, + int *argcp, + gchar ***argvp, + GError **error) +{ + gboolean ret = FALSE; + GOptionContext *context = NULL; + GOptionEntry entries[] = + { + { "hooks", '\0', G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME_ARRAY, + &self->options.hooks, + "Directory to read to find hook scripts (can be repeated, " + "most important first)", + "DIR" }, + { "entry-point", '\0', G_OPTION_FLAG_NONE, G_OPTION_ARG_FILENAME, + &self->options.entry_point, + "devkit-1 entry point" }, + { "close-stdout-when-ready", '\0', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, + &self->options.close_stdout_when_ready, + "Close the standard output file descriptor (which should be a pipe) " + "when ready to receive requests" }, + { "default-hooks", '\0', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, + &self->options.use_default_hooks, + "Use the default search path for hook scripts after anything " + "specified with --hooks [default]" }, + { "no-default-hooks", '\0', G_OPTION_FLAG_REVERSE, G_OPTION_ARG_NONE, + &self->options.use_default_hooks, + "Don't use the default search path for hook scripts after " + "anything specified with --hooks" }, + { "system-log", '\0', G_OPTION_FLAG_HIDDEN|G_OPTION_FLAG_NO_ARG, + G_OPTION_ARG_CALLBACK, deprecated_option_cb, "Does nothing" }, + { "no-system-log", '\0', G_OPTION_FLAG_HIDDEN|G_OPTION_FLAG_NO_ARG, + G_OPTION_ARG_CALLBACK, deprecated_option_cb, "Does nothing" }, + { NULL } + }; + GSList *uris = NULL; + GSList *u; + gchar **hook; + gchar *cwd = NULL; + GPtrArray *devkit1_argv; + + context = g_option_context_new ("- devkit discovery server"); + g_option_context_add_main_entries (context, entries, NULL); + + if (!g_option_context_parse (context, argcp, argvp, error)) + goto out; + + /* Make hooks absolute */ + for (hook = self->options.hooks; hook != NULL && *hook != NULL; hook++) + { + if (*hook[0] != '/') + { + gchar *tmp = *hook; + + if (cwd == NULL) + cwd = g_get_current_dir (); + + *hook = g_build_filename (cwd, tmp, NULL); + g_free (tmp); + } + } + + devkit1_argv = g_ptr_array_new_with_free_func (g_free); + if (self->options.entry_point != NULL) + { + g_ptr_array_add (devkit1_argv, g_strdup (self->options.entry_point)); + } + else + { + g_ptr_array_add (devkit1_argv, g_strdup ("devkit-1")); + } + + if (self->options.hooks != NULL && self->options.hooks[0] != NULL) + { + gchar **iter; + + for (iter = self->options.hooks; *iter != NULL; iter++) + g_ptr_array_add (devkit1_argv, + g_strdup_printf ("--hooks=%s", *iter)); + } + + if (!self->options.use_default_hooks) + g_ptr_array_add (devkit1_argv, g_strdup ("--no-default-hooks")); + + g_ptr_array_add (devkit1_argv, NULL); + + /* "Steal" contents of GPtrArray as a gchar ** */ + g_strfreev (self->devkit1_argv); + self->devkit1_argv = (gchar **) g_ptr_array_free (devkit1_argv, FALSE); + + g_bus_get (G_BUS_TYPE_SYSTEM, NULL, system_bus_get_cb, + g_object_ref (self)); + + self->hup_signal_watch = g_unix_signal_add (SIGHUP, hup_signal_cb, self); + + if (!dkd_application_load_configuration (self, error)) + goto out; + + if (!soup_server_listen_all (self->server, self->server_port, 0, error)) + { + // TODO: Maybe try the next port, etc. until one works, then save the + // value in user_config or config... + g_prefix_error (error, "Failed to listen on port: %ld", + self->server_port); + goto out; + } + + soup_server_add_handler (self->server, "/", server_callback, self, NULL); + + uris = soup_server_get_uris (self->server); + + for (u = uris; u; u = u->next) + { + gchar *str = soup_uri_to_string (u->data, FALSE); + + g_debug ("Listening on %s", str); + g_free (str); + soup_uri_free (u->data); + } + + if (!dkd_application_publish (self, error)) + goto out; + + if (self->options.close_stdout_when_ready) + { + if (dup2 (STDERR_FILENO, STDOUT_FILENO) != STDOUT_FILENO) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Unable to make stdout a copy of stderr: %s", + g_strerror (errno)); + goto out; + } + } + + ret = TRUE; + +out: + if (context != NULL) + g_option_context_free (context); + + g_free (cwd); + g_slist_free (uris); + return ret; +} + +static void +dkd_application_dispose (GObject *object) +{ + DkdApplication *self = DKD_APPLICATION (object); + + if (self->system_bus != NULL && + self->hostname_property_changed_subscription != 0) + { + g_dbus_connection_signal_unsubscribe ( + self->system_bus, + self->hostname_property_changed_subscription); + self->hostname_property_changed_subscription = 0; + } + + g_clear_object (&self->system_bus); + + if (self->hup_signal_watch != 0) + { + g_source_remove (self->hup_signal_watch); + self->hup_signal_watch = 0; + } + + if (self->server != NULL) + { + soup_server_remove_handler (self->server, "/"); + soup_server_disconnect (self->server); + g_clear_object (&self->server); + } + + g_clear_object (&self->avahi_service); + + G_OBJECT_CLASS (dkd_application_parent_class)->dispose (object); +} + +static void +dkd_application_finalize (GObject *object) +{ + DkdApplication *self = DKD_APPLICATION (object); + + g_clear_pointer (&self->config, g_key_file_unref); + g_clear_pointer (&self->user_config, g_key_file_unref); + self->entry_point_user = NULL; + g_clear_pointer (&self->users, g_strfreev); + g_clear_pointer (&self->loop, g_main_loop_unref); + + G_OBJECT_CLASS (dkd_application_parent_class)->finalize (object); +} + +static void +dkd_application_class_init (DkdApplicationClass *cls) +{ + GObjectClass *object_class = G_OBJECT_CLASS (cls); + + object_class->dispose = dkd_application_dispose; + object_class->finalize = dkd_application_finalize; +} + +int +main (int argc, char **argv) +{ + DkdApplication *app; + GError *error = NULL; + int ret = 0; + + app = g_object_new (DKD_TYPE_APPLICATION, NULL); + + if (!dkd_application_start (app, &argc, &argv, &error)) + { + if (error != NULL) + { + g_warning ("%s", error->message); + g_clear_error (&error); + } + else + { + g_warning("Unable to start devkit daemon application"); + } + ret = 1; + goto out; + } + + sd_notify (0, "READY=1"); + g_main_loop_run (app->loop); + +out: + if (app != NULL && app->avahi_service != NULL) + dkd_avahi_service_stop (app->avahi_service); + + g_clear_object (&app); + + return ret; +} diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000000000000000000000000000000000000..ec8ffe784445fcbb6042996f7a4b29f45e478e4d --- /dev/null +++ b/src/meson.build @@ -0,0 +1,11 @@ +project('steamos-devkit-service', 'c') +dependencies = [ + dependency('glib-2.0'), + dependency('gio-unix-2.0'), + dependency('json-glib-1.0'), + dependency('libsoup-2.4'), + dependency('avahi-client'), + dependency('avahi-glib'), + dependency('libsystemd'), +] +executable('steamos-devkit-service', ['main.c', 'avahi-service.c', 'utils.c', 'json.c'], dependencies : dependencies) diff --git a/src/utils.c b/src/utils.c new file mode 100644 index 0000000000000000000000000000000000000000..21637812c6e51a78a86660484cd3522de51518cf --- /dev/null +++ b/src/utils.c @@ -0,0 +1,317 @@ +/* + * This file is part of steamos-devkit + * SPDX-License-Identifier: LGPL-2.1+ + * + * Copyright © 2018 Collabora Ltd + * Incorporates code from GLib, copyright © 2009 Codethink 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 "utils.h" +#include "defines.h" + +#include <gio/gio.h> + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +/* + * Windows command-line parsing makes even Unix /bin/sh look + * well-documented and consistent. To make life easier for the Windows + * client-side, escape anything that could become a problem. + * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + */ +gchar * +dk_sanitize_machine_name (const gchar *machine_name) +{ + const char *p = machine_name; + GString *buffer = NULL; + gsize i; + + g_return_val_if_fail (machine_name != NULL, NULL); + + buffer = g_string_new (""); + + while (*p != '\0') + { + gunichar u = g_utf8_get_char_validated (p, -1); + + /* Not UTF-8? Turn it into underscores. */ + if (u == (gunichar) -1 || u == (gunichar) -2) + { + g_string_append_c (buffer, '_'); + p++; + continue; + } + + /* + * Allow all printable Unicode outside the ASCII range, plus the + * ASCII punctuation that is not special for cmd.exe, PowerShell, + * CommandLineFromArgvW (inside double quotes), Windows filenames, + * or DNS. + * + * We forbid <>:"/\|?* because they are not allowed in filenames, + * . because it's special in filenames and DNS, "`$ because they + * are special in PowerShell, "\%^ because they are special in + * cmd.exe, "\ because they are special for CommandLineFromArgvW. + * There isn't a whole lot left. + */ + if (u > 127) + { + if (g_unichar_isprint (u)) + g_string_append_unichar (buffer, u); + else + g_string_append_c (buffer, '_'); + } + else + { + if (g_ascii_isalnum ((char) u) || + strchr (" !#&'()+,-;=@[]_{}~", (char) u) != NULL) + g_string_append_unichar (buffer, u); + else + g_string_append_c (buffer, '_'); + } + + p = g_utf8_next_char (p); + } + + for (i = 0; i < buffer->len; i++) + { + if (!g_ascii_isspace (buffer->str[i])) + break; + } + + g_string_erase (buffer, 0, i); + + for (i = buffer->len; i > 0; i--) + { + if (!g_ascii_isspace (buffer->str[i - 1])) + break; + } + + g_string_truncate (buffer, i); + + /* DNS-SD machine names are DNS labels, limited to 63 bytes, so if + * it's longer than that we truncate to no more than 60 and append + * an ellipsis */ + if (buffer->len > 63) + { + while (buffer->len > 60) + { + const gchar *nul = &buffer->str[buffer->len]; + const gchar *prev; + + prev = g_utf8_find_prev_char (buffer->str, nul); + + g_string_truncate (buffer, prev - buffer->str); + } + + g_string_append (buffer, "\xe2\x80\xa6"); + } + + if (buffer->len == 0) + g_string_append_c (buffer, '_'); + + return g_string_free (buffer, FALSE); +} + +/* + * Returns: (transfer full): The absolute path of the hook script + */ +gchar * +dk_find_hook (const gchar * const *hook_dirs, + gboolean use_default_hooks, + const gchar *name, + GError **error) +{ + const gchar * const *hook_dir; + gchar *script; + + g_return_val_if_fail (name != NULL, NULL); + + for (hook_dir = hook_dirs; + hook_dir != NULL && *hook_dir != NULL; + hook_dir++) + { + script = g_build_filename (*hook_dir, name, NULL); + + if (access (script, X_OK) == 0) + return script; + + g_free (script); + } + + if (!use_default_hooks) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Unable to find script \"%s\" in specified " + "hook directories", + name); + return NULL; + } + + script = g_strdup_printf ("/etc/%s/hooks/%s", PACKAGE, name); + + if (access (script, X_OK) == 0) + return script; + + g_free (script); + + script = g_strdup_printf ("%s/%s", DEVKIT_HOOKS_DIR, name); + + if (access (script, X_OK) == 0) + return script; + + g_free (script); + + script = g_strdup_printf ("%s/%s.sample", DEVKIT_HOOKS_DIR, name); + + if (access (script, X_OK) == 0) + return script; + + g_free (script); + + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Unable to find script \"%s\" in \"/etc/%s/hooks\" " + "or \"%s\"", + name, PACKAGE, DEVKIT_HOOKS_DIR); + return NULL; +} + +/* Taken from gio/gunixfdlist.c */ +int +dk_dup_close_on_exec_fd (gint fd, GError ** error) +{ + gint new_fd; + gint s; + +#ifdef F_DUPFD_CLOEXEC + do + new_fd = fcntl (fd, F_DUPFD_CLOEXEC, 0l); + while (new_fd < 0 && (errno == EINTR)); + + if (new_fd >= 0) + return new_fd; + + /* if that didn't work (new libc/old kernel?), try it the other way. */ +#endif + + do + new_fd = dup (fd); + while (new_fd < 0 && (errno == EINTR)); + + if (new_fd < 0) + { + int saved_errno = errno; + + g_set_error (error, G_IO_ERROR, + g_io_error_from_errno (saved_errno), + "dup: %s", g_strerror (saved_errno)); + + return -1; + } + + do + { + s = fcntl (new_fd, F_GETFD); + + if (s >= 0) + s = fcntl (new_fd, F_SETFD, (long) (s | FD_CLOEXEC)); + } + while (s < 0 && (errno == EINTR)); + + if (s < 0) + { + int saved_errno = errno; + + g_set_error (error, G_IO_ERROR, + g_io_error_from_errno (saved_errno), + "fcntl: %s", g_strerror (saved_errno)); + close (new_fd); + + return -1; + } + + return new_fd; +} + +/* + * Read the first few bytes of @script and return TRUE if its first + * line is `#!/usr/bin/env python`, possibly with some extra whitespace. + * The devkit server special-cases these scripts to be run by Python 3 + * if available, or Python 2 otherwise. + * + * Note that if the script starts with "#!/usr/bin/python", + * "#!/usr/bin/python2", "#!/usr/bin/python3", "#!/usr/bin/env python2" + * or "#!/usr/bin/python3", that doesn't count as a generic version of + * Python for our purposes. + */ +gboolean +dk_hook_is_generic_python (const gchar *script) +{ + FILE *fh; + char buf[80]; + size_t bytes_read; + + fh = fopen (script, "rb"); + + if (fh == NULL) + return FALSE; + + bytes_read = fread (buf, 1, sizeof (buf) - 1, fh); + buf[bytes_read] = '\0'; + fclose (fh); + + if (g_str_has_prefix (buf, "#!") && + g_regex_match_simple ("#![ \t]*/usr/bin/env[ \t]+python[ \t]*\n", + buf, G_REGEX_ANCHORED, 0)) + return TRUE; + + return FALSE; +} + +/* + * Return a Python interpreter. + * + * The result is guaranteed to be non-NULL, but is not guaranteed to + * exist: if neither python3 nor python exists in the PATH, we'll + * try to run "python whatever.py" and fail, which is error-handling + * that we'd need to have anyway. + * + * Returns: "python3", or "python" if there is no python3 in PATH + */ +const char * +dk_get_best_python (void) +{ + static const char *best_python = NULL; + gchar *found; + + if (best_python != NULL) + return best_python; + + found = g_find_program_in_path ("python3"); + + if (found != NULL) + best_python = "python3"; + else + best_python = "python"; + + g_free (found); + return best_python; +} diff --git a/src/utils.h b/src/utils.h new file mode 100644 index 0000000000000000000000000000000000000000..53380b8a2ea4d5365f688e52ac320c5a6d51791a --- /dev/null +++ b/src/utils.h @@ -0,0 +1,32 @@ +/* + * This file is part of steamos-devkit + * SPDX-License-Identifier: LGPL-2.1+ + * + * Copyright © 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/>. + */ + +#pragma once + +#include <glib.h> + +gchar *dk_find_hook (const gchar * const *hook_dirs, + gboolean use_default_hooks, + const gchar *name, GError **error); +gchar *dk_sanitize_machine_name (const gchar *machine_name); +int dk_dup_close_on_exec_fd (gint fd, GError **error); +gboolean dk_hook_is_generic_python (const gchar *script); +const char * dk_get_best_python (void);