Skip to content
Snippets Groups Projects
Commit f9823c26 authored by Jeremy Whiting's avatar Jeremy Whiting
Browse files

Merge branch 'pythonrewrite' into 'main'

Rewrite devkit service in python.

See merge request !1
parents e0283b1f d411a33b
Branches
Tags v0.20220330.0
1 merge request!1Rewrite devkit service in python.
/*
* 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);
}
/*
* 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);
// 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"
/*
* 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
/*
* 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
This diff is collapsed.
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,
install: true,
)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MIT License
#
# Copyright (c) 2022 Valve Software inc., Collabora Ltd
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from http.server import BaseHTTPRequestHandler
import configparser
import json
import os
import platform
import socketserver
import subprocess
import tempfile
import urllib.parse
import dbus
import avahi
SERVICE_PORT = 32000
PACKAGE = "steamos-devkit-service"
DEVKIT_HOOKS_DIR = "/usr/share/steamos-devkit/hooks"
CURRENT_TXTVERS = "txtvers=1"
ENTRY_POINT = "devkit-1"
# root until config is loaded and told otherwise, etc.
ENTRY_POINT_USER = "root"
DEVICE_USERS = []
PROPERTIES = {"txtvers": 1,
"login": ENTRY_POINT_USER,
"settings": "",
"devkit1": [
ENTRY_POINT
]}
HOOK_DIRS = []
USE_DEFAULT_HOOKS = True
def write_file(data: bytes) -> str:
""" Write given bytes to a temporary file and return the filename
Return the empty string if unable to open temp file for some reason
"""
with tempfile.NamedTemporaryFile(mode='w', prefix='devkit-1', encoding='utf-8',
delete=False) as file:
file.write(data.decode())
return file.name
return ''
def write_key(post_body: bytes) -> str:
""" Write key to temp file and return filename if valid
Return the empty string if invalid
"""
length = len(post_body)
found_name = False
if length >= 64 * 1024:
print("Key length too long")
return ''
if not post_body.decode().startswith('ssh-rsa '):
print("Key doesn't start with ssh-rsa ")
return ''
# Get to the base64 bits
index = 8
while index < length and post_body[index] == ' ':
index = index + 1
# Make sure key is base64
body_decoded = post_body.decode()
while index < length:
if ((body_decoded[index] == '+') or (body_decoded[index] == '/') or
(body_decoded[index].isdigit()) or
(body_decoded[index].isalpha())):
index = index + 1
continue
if body_decoded[index] == '=':
index = index + 1
if (index < length) and (body_decoded[index] == ' '):
break
if (index < length) and (body_decoded[index] == '='):
index = index + 1
if (index < length) and (body_decoded[index] == ' '):
break
print("Found = but no space or = next, invalid key")
return ''
if body_decoded[index] == ' ':
break
print("Found invalid data, invalid key at "
f"index: {index} data: {body_decoded[index]}")
return ''
print(f"Key is valid base64, writing to temp file index: {index}")
while index < length:
if body_decoded[index] == ' ':
# it's a space, the rest is name or magic phrase, don't write to disk
if found_name:
print(f"Found name ending at index {index}")
length = index
else:
print(f"Found name ending index {index}")
found_name = True
if body_decoded[index] == '\0':
print("Found null terminator before expected")
return ''
if body_decoded[index] == '\n' and index != length - 1:
print("Found newline before expected")
return ''
index = index + 1
# write data to the file
data = body_decoded[:length]
filename = write_file(data.encode())
if filename:
print(f"Filename key written to: {filename}")
return filename
def find_hook(name: str) -> str:
""" Find a hook with the given name
Return the path to the hook if found. '' if not found
"""
# First see if it exists in the given paths.
for path in HOOK_DIRS:
test_path = os.path.join(path, name)
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
return test_path
if not USE_DEFAULT_HOOKS:
print(f"Error: Unable to find hook for {name} in hook directories\n")
return ''
test_path = f"/etc/{PACKAGE}/hooks/{name}"
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
return test_path
test_path = f"{DEVKIT_HOOKS_DIR}/{name}"
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
return test_path
test_path = f"{DEVKIT_HOOKS_DIR}/{name}.sample"
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
return test_path
test_path = f"{os.path.dirname(os.path.realpath(__file__))}/../hooks/{name}"
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
return test_path
print(f"Error:: Unable to find hook for {name} in /etc/{PACKAGE}/hooks or {DEVKIT_HOOKS_DIR}")
return ''
def get_machine_name() -> str:
""" Get the machine name and return it in a string
Use identify hook first, and if that fails just get the hostname.
"""
machine_name = ''
# Run devkit-1-identify hook to get hostname, otherwise use default platform.node()
identify_hook = find_hook("devkit-1-identify")
if identify_hook:
# Run hook and parse machine_name out
process = subprocess.Popen(identify_hook, shell=False, stdout=subprocess.PIPE)
output = ''
for line in process.stdout:
textline = line.decode(encoding='utf-8', errors="ignore")
output += textline
process.wait()
output_object = json.loads(output)
if 'machine_name' in output_object:
machine_name = output_object["machine_name"]
if not machine_name:
machine_name = platform.node()
return machine_name
class DevkitHandler(BaseHTTPRequestHandler):
""" Class to handle http requests on selected port for registration, getting properties.
"""
def _send_headers(self, code, content_type):
self.send_response(code)
self.send_header("Content-type", content_type)
self.end_headers()
def do_GET(self):
""" Handle GET requests
"""
print(f"GET request to path {self.path} from {self.client_address[0]}")
if self.path == "/login-name":
self._send_headers(200, "text/plain")
self.wfile.write(ENTRY_POINT_USER.encode())
return
if self.path == "/properties.json":
self._send_headers(200, "application/json")
self.wfile.write(json.dumps(PROPERTIES, indent=2).encode())
return
query = urllib.parse.parse_qs(self.path[2:])
print(f"query is {query}")
if len(query) > 0 and query["command"]:
command = query["command"][0]
if command == "ping":
self._send_headers(200, "text/plain")
self.wfile.write("pong\n".encode())
return
self._send_headers(404, "")
return
self._send_headers(404, "")
self.wfile.write("Unknown request\n".encode())
def do_POST(self):
""" Handle POST requests
"""
if self.path == "/register":
from_ip = self.client_address[0]
content_len = int(self.headers.get('Content-Length'))
post_body = self.rfile.read(content_len)
print(f"register request from {from_ip}")
filename = write_key(post_body)
if not filename:
self._send_headers(403, "text/plain")
self.wfile.write(b"Failed to write ssh key\n")
return
# Run approve script
approve_hook = find_hook("approve-ssh-key")
if not approve_hook:
self._send_headers(403, "text/plain")
self.wfile.write(b"Failed to find approve hook\n")
os.unlink(filename)
return
# Run hook and parse output
approve_process = subprocess.Popen([approve_hook, filename, from_ip],
shell=False,
stdout=subprocess.PIPE)
approve_output = ''
for approve_line in approve_process.stdout:
approve_textline = approve_line.decode(encoding='utf-8', errors="ignore")
approve_output += approve_textline
approve_process.wait()
approve_object = json.loads(approve_output)
if "error" in approve_object:
self._send_headers(403, "text/plain")
self.wfile.write("approve-ssh-key:\n".encode())
self.wfile.write(approve_object["error"].encode())
os.unlink(filename)
return
# Otherwise, assume it passed
install_hook = find_hook("install-ssh-key")
if not install_hook:
self._send_headers(403, "text-plain")
self.wfile.write(b"Failed to find install-ssh-key hook\n")
os.unlink(filename)
return
command = [install_hook, filename]
# Append each user to command as separate arguments
for user in DEVICE_USERS:
command.append(user)
install_process = subprocess.Popen(command, shell=False, stdout=subprocess.PIPE)
install_output = ''
for install_line in install_process.stdout:
install_textline = install_line.decode(encoding='utf-8', errors="ignore")
install_output += install_textline
install_process.wait()
exit_code = install_process.returncode
if exit_code != 0:
self._send_headers(500, "text/plain")
self.wfile.write("install-ssh-key:\n".encode())
self.wfile.write(install_output.encode())
os.unlink(filename)
return
self._send_headers(200, "text/plain")
self.wfile.write("Registered\n".encode())
os.unlink(filename)
class DevkitService:
""" Class to run as service.
Parses configuration, creates handler, registers with avahi, etc.
"""
def __init__(self):
global ENTRY_POINT_USER
global DEVICE_USERS
self.port = SERVICE_PORT
self.name = get_machine_name()
self.host = ""
self.domain = ""
self.stype = "_steamos-devkit._tcp"
self.text = ""
self.group = None
config = configparser.ConfigParser()
# Use str form to preserve case
config.optionxform = str
config.read(["/etc/steamos-devkit/steamos-devkit.conf",
"/usr/share/steamos-devkit/steamos-devkit.conf",
os.path.join(os.path.expanduser('~'), '.config', PACKAGE, PACKAGE + '.conf')])
if 'Settings' in config:
settings = config["Settings"]
self.settings = dict(settings)
if 'Port' in settings:
self.port = int(settings["Port"])
PROPERTIES["settings"] = json.dumps(self.settings)
# Parse users from configs
if os.geteuid() == 0:
# Running as root, maybe warn?
print("Running as root, Probably shouldn't be\n")
if 'Users' in config:
users = config["Users"]
if 'ShellUsers' in users:
DEVICE_USERS = users["ShellUsers"]
else:
if 'Users' in config:
users = config["Users"]
if 'ShellUsers' in users:
DEVICE_USERS = users["ShellUsers"]
else:
DEVICE_USERS = [os.getlogin()]
# If only one user, that's the entry point user
# Otherwise entry_point_user needs to be root to be able to switch between users
if len(DEVICE_USERS) == 1:
ENTRY_POINT_USER = DEVICE_USERS[0]
PROPERTIES["login"] = ENTRY_POINT_USER
self.httpd = socketserver.TCPServer(("", self.port), DevkitHandler, bind_and_activate=False)
print(f"serving at port: {self.port}")
print(f"machine name: {self.name}")
self.httpd.allow_reuse_address = True
self.httpd.server_bind()
self.httpd.server_activate()
def publish(self):
""" Publish ourselves on avahi mdns system as an available devkit device.
"""
bus = dbus.SystemBus()
self.text = [f"{CURRENT_TXTVERS}".encode(),
f"settings={json.dumps(self.settings)}".encode(),
f"login={ENTRY_POINT_USER}".encode(),
f"devkit1={ENTRY_POINT}".encode()
]
server = dbus.Interface(
bus.get_object(
avahi.DBUS_NAME,
avahi.DBUS_PATH_SERVER),
avahi.DBUS_INTERFACE_SERVER)
avahi_object = dbus.Interface(
bus.get_object(avahi.DBUS_NAME,
server.EntryGroupNew()),
avahi.DBUS_INTERFACE_ENTRY_GROUP)
avahi_object.AddService(avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0),
self.name, self.stype, self.domain, self.host,
dbus.UInt16(int(self.port)), self.text)
avahi_object.Commit()
self.group = avahi_object
def unpublish(self):
""" Remove publishing of ourselves as devkit device since we are quitting.
"""
self.group.Reset()
def run_server(self):
""" Run server until keyboard interrupt or we are killed
"""
try:
self.httpd.serve_forever()
except KeyboardInterrupt:
pass
self.httpd.server_close()
print(f"done serving at port: {self.port}")
if __name__ == "__main__":
service = DevkitService()
service.publish()
service.run_server()
service.unpublish()
/*
* 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;
}
/*
* 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);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment