/* * Copyright (C) 2017-2018 Richard Hughes * * SPDX-License-Identifier: LGPL-2.1+ */ #include "config.h" #include #include #include #include "fwupd-error.h" #include "fwupd-enums.h" #include "fu-device.h" #include "fu-redfish-client.h" #include "fu-redfish-common.h" struct _FuRedfishClient { GObject parent_instance; SoupSession *session; gchar *hostname; guint port; gchar *username; gchar *password; gchar *update_uri_path; gchar *push_uri_path; gboolean auth_created; gboolean use_https; gboolean cacheck; GPtrArray *devices; }; G_DEFINE_TYPE (FuRedfishClient, fu_redfish_client, G_TYPE_OBJECT) static void fu_redfish_client_set_auth (FuRedfishClient *self, SoupURI *uri, SoupMessage *msg) { if ((self->username != NULL && self->password != NULL) && self->auth_created == FALSE) { /* * Some redfish implementations miss WWW-Authenticate * header for a 401 response, and SoupAuthManager couldn't * generate SoupAuth accordingly. Since DSP0266 makes * Basic Authorization a requirement for redfish, it shall be * safe to use Basic Auth for all redfish implementations. */ SoupAuthManager *manager = SOUP_AUTH_MANAGER (soup_session_get_feature (self->session, SOUP_TYPE_AUTH_MANAGER)); g_autoptr(SoupAuth) auth = soup_auth_new (SOUP_TYPE_AUTH_BASIC, msg, "Basic"); soup_auth_authenticate (auth, self->username, self->password); soup_auth_manager_use_auth (manager, uri, auth); self->auth_created = TRUE; } } static GBytes * fu_redfish_client_fetch_data (FuRedfishClient *self, const gchar *uri_path, GError **error) { guint status_code; g_autoptr(SoupMessage) msg = NULL; g_autoptr(SoupURI) uri = NULL; /* create URI */ uri = soup_uri_new (NULL); soup_uri_set_scheme (uri, self->use_https ? "https" : "http"); soup_uri_set_path (uri, uri_path); soup_uri_set_host (uri, self->hostname); soup_uri_set_port (uri, self->port); msg = soup_message_new_from_uri (SOUP_METHOD_GET, uri); if (msg == NULL) { g_autofree gchar *tmp = soup_uri_to_string (uri, FALSE); g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "failed to create message for URI %s", tmp); return NULL; } fu_redfish_client_set_auth (self, uri, msg); status_code = soup_session_send_message (self->session, msg); if (status_code != SOUP_STATUS_OK) { g_autofree gchar *tmp = soup_uri_to_string (uri, FALSE); g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "failed to download %s: %s", tmp, soup_status_get_phrase (status_code)); return NULL; } return g_bytes_new (msg->response_body->data, msg->response_body->length); } static gboolean fu_redfish_client_coldplug_member (FuRedfishClient *self, JsonObject *member, GError **error) { g_autoptr(FuDevice) dev = NULL; const gchar *guid = NULL; g_autofree gchar *id = NULL; if (json_object_has_member (member, "SoftwareId")) { guid = json_object_get_string_member (member, "SoftwareId"); } else if (json_object_has_member (member, "Oem")) { JsonObject *oem = json_object_get_object_member (member, "Oem"); if (oem != NULL && json_object_has_member (oem, "Hpe")) { JsonObject *hpe = json_object_get_object_member (oem, "Hpe"); if (hpe != NULL && json_object_has_member (hpe, "DeviceClass")) guid = json_object_get_string_member (hpe, "DeviceClass"); } } /* skip the devices without guid */ if (guid == NULL) return TRUE; dev = fu_device_new (); id = g_strdup_printf ("Redfish-Inventory-%s", json_object_get_string_member (member, "Id")); fu_device_set_id (dev, id); fu_device_set_protocol (dev, "org.dmtf.redfish"); fu_device_add_guid (dev, guid); if (json_object_has_member (member, "Name")) fu_device_set_name (dev, json_object_get_string_member (member, "Name")); fu_device_set_summary (dev, "Redfish device"); if (json_object_has_member (member, "Version")) fu_device_set_version (dev, json_object_get_string_member (member, "Version")); if (json_object_has_member (member, "LowestSupportedVersion")) fu_device_set_version_lowest (dev, json_object_get_string_member (member, "LowestSupportedVersion")); if (json_object_has_member (member, "Description")) fu_device_set_description (dev, json_object_get_string_member (member, "Description")); if (json_object_has_member (member, "Updateable")) { if (json_object_get_boolean_member (member, "Updateable")) fu_device_add_flag (dev, FWUPD_DEVICE_FLAG_UPDATABLE); } else { /* assume the device is updatable */ fu_device_add_flag (dev, FWUPD_DEVICE_FLAG_UPDATABLE); } /* success */ g_ptr_array_add (self->devices, g_steal_pointer (&dev)); return TRUE; } static gboolean fu_redfish_client_coldplug_collection (FuRedfishClient *self, JsonObject *collection, GError **error) { JsonArray *members; JsonNode *node_root; JsonObject *member; members = json_object_get_array_member (collection, "Members"); for (guint i = 0; i < json_array_get_length (members); i++) { g_autoptr(JsonParser) parser = json_parser_new (); g_autoptr(GBytes) blob = NULL; JsonObject *member_id; const gchar *member_uri; member_id = json_array_get_object_element (members, i); member_uri = json_object_get_string_member (member_id, "@odata.id"); if (member_uri == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_FOUND, "no @odata.id string"); return FALSE; } /* try to connect */ blob = fu_redfish_client_fetch_data (self, member_uri, error); if (blob == NULL) return FALSE; /* get the member object */ if (!json_parser_load_from_data (parser, g_bytes_get_data (blob, NULL), (gssize) g_bytes_get_size (blob), error)) { g_prefix_error (error, "failed to parse node: "); return FALSE; } node_root = json_parser_get_root (parser); if (node_root == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no root node"); return FALSE; } member = json_node_get_object (node_root); if (member == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no member object"); return FALSE; } /* Create the device for the member */ if (!fu_redfish_client_coldplug_member (self, member, error)) return FALSE; } return TRUE; } static gboolean fu_redfish_client_coldplug_inventory (FuRedfishClient *self, JsonObject *inventory, GError **error) { g_autoptr(JsonParser) parser = json_parser_new (); g_autoptr(GBytes) blob = NULL; JsonNode *node_root; JsonObject *collection; const gchar *collection_uri; if (inventory == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_FOUND, "no inventory object"); return FALSE; } collection_uri = json_object_get_string_member (inventory, "@odata.id"); if (collection_uri == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_FOUND, "no @odata.id string"); return FALSE; } /* try to connect */ blob = fu_redfish_client_fetch_data (self, collection_uri, error); if (blob == NULL) return FALSE; /* get the inventory object */ if (!json_parser_load_from_data (parser, g_bytes_get_data (blob, NULL), (gssize) g_bytes_get_size (blob), error)) { g_prefix_error (error, "failed to parse node: "); return FALSE; } node_root = json_parser_get_root (parser); if (node_root == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no root node"); return FALSE; } collection = json_node_get_object (node_root); if (collection == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no collection object"); return FALSE; } return fu_redfish_client_coldplug_collection (self, collection, error); } gboolean fu_redfish_client_coldplug (FuRedfishClient *self, GError **error) { JsonNode *node_root; JsonObject *obj_root = NULL; g_autoptr(GBytes) blob = NULL; g_autoptr(JsonParser) parser = json_parser_new (); /* nothing set */ if (self->update_uri_path == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INTERNAL, "no update_uri_path"); return FALSE; } /* try to connect */ blob = fu_redfish_client_fetch_data (self, self->update_uri_path, error); if (blob == NULL) return FALSE; /* get the update service */ if (!json_parser_load_from_data (parser, g_bytes_get_data (blob, NULL), (gssize) g_bytes_get_size (blob), error)) { g_prefix_error (error, "failed to parse node: "); return FALSE; } node_root = json_parser_get_root (parser); if (node_root == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no root node"); return FALSE; } obj_root = json_node_get_object (node_root); if (obj_root == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no root object"); return FALSE; } if (!json_object_get_boolean_member (obj_root, "ServiceEnabled")) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_SUPPORTED, "service is not enabled"); return FALSE; } if (!json_object_has_member (obj_root, "HttpPushUri")) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_SUPPORTED, "HttpPushUri is not available"); return FALSE; } self->push_uri_path = g_strdup (json_object_get_string_member (obj_root, "HttpPushUri")); if (self->push_uri_path == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_SUPPORTED, "HttpPushUri is invalid"); return FALSE; } if (json_object_has_member (obj_root, "FirmwareInventory")) { JsonObject *tmp = json_object_get_object_member (obj_root, "FirmwareInventory"); return fu_redfish_client_coldplug_inventory (self, tmp, error); } if (json_object_has_member (obj_root, "SoftwareInventory")) { JsonObject *tmp = json_object_get_object_member (obj_root, "SoftwareInventory"); return fu_redfish_client_coldplug_inventory (self, tmp, error); } return TRUE; } static gboolean fu_redfish_client_set_uefi_credentials (FuRedfishClient *self, GError **error) { guint32 indications_le; g_autofree gchar *userpass_safe = NULL; g_auto(GStrv) split = NULL; g_autoptr(GBytes) indications = NULL; g_autoptr(GBytes) userpass = NULL; /* get the uint32 specifying if there are EFI variables set */ indications = fu_redfish_common_get_evivar_raw (REDFISH_EFI_INFORMATION_GUID, REDFISH_EFI_INFORMATION_INDICATIONS, error); if (indications == NULL) return FALSE; if (g_bytes_get_size (indications) != 4) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "invalid value for %s, got %" G_GSIZE_FORMAT " bytes", REDFISH_EFI_INFORMATION_INDICATIONS, g_bytes_get_size (indications)); return FALSE; } memcpy (&indications_le, g_bytes_get_data (indications, NULL), 4); if ((indications_le & REDFISH_EFI_INDICATIONS_OS_CREDENTIALS) == 0) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no indications for OS credentials"); return FALSE; } /* read the correct EFI var for runtime */ userpass = fu_redfish_common_get_evivar_raw (REDFISH_EFI_INFORMATION_GUID, REDFISH_EFI_INFORMATION_OS_CREDENTIALS, error); if (userpass == NULL) return FALSE; /* it might not be NUL terminated */ userpass_safe = g_strndup (g_bytes_get_data (userpass, NULL), g_bytes_get_size (userpass)); split = g_strsplit (userpass_safe, ":", -1); if (g_strv_length (split) != 2) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "invalid format for username:password, got '%s'", userpass_safe); return FALSE; } fu_redfish_client_set_username (self, split[0]); fu_redfish_client_set_password (self, split[1]); return TRUE; } static void fu_redfish_client_parse_interface_data (const guint8 *buf, guint8 sz) { switch (buf[0]) { case REDFISH_INTERFACE_TYPE_USB_NEWORK: g_debug ("USB Network Interface"); /* * uint16 idVendor(2-bytes) * uint16 idProduct(2-bytes) * uint8 SerialNumberLen: * uint8 DescriptorType: * uint8* SerialNumber: */ break; case REDFISH_INTERFACE_TYPE_PCI_NEWORK: g_debug ("PCI Network Interface"); /* * uint16 VendorID * uint16 DeviceID * uint16 Subsystem_Vendor_ID * uint16 Subsystem_ID */ break; default: break; } } typedef struct __attribute__((packed)) { guint8 service_uuid[16]; guint8 host_ip_assignment_type; guint8 host_ip_address_format; guint8 host_ip_address[16]; guint8 host_ip_mask[16]; guint8 service_ip_assignment_type; guint8 service_ip_address_format; guint8 service_ip_address[16]; guint8 service_ip_mask[16]; guint16 service_ip_port; guint32 service_ip_vlan_id; guint8 service_hostname_len; /* service_hostname; */ } RedfishProtocolDataOverIp; static gboolean fu_redfish_client_parse_protocol_data (FuRedfishClient *self, const guint8 *buf, guint8 sz, GError **error) { RedfishProtocolDataOverIp *pr; if (sz < sizeof(RedfishProtocolDataOverIp)) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "protocol data too small"); return FALSE; } pr = (RedfishProtocolDataOverIp *) buf; /* parse the hostname and port */ if (pr->service_ip_assignment_type == REDFISH_IP_ASSIGNMENT_TYPE_STATIC || pr->service_ip_assignment_type == REDFISH_IP_ASSIGNMENT_TYPE_AUTO_CONFIG) { if (pr->service_ip_address_format == REDFISH_IP_ADDRESS_FORMAT_V4) { g_autofree gchar *tmp = NULL; tmp = fu_redfish_common_buffer_to_ipv4 (pr->service_ip_address); fu_redfish_client_set_hostname (self, tmp); } else if (pr->service_ip_address_format == REDFISH_IP_ADDRESS_FORMAT_V6) { g_autofree gchar *tmp = NULL; tmp = fu_redfish_common_buffer_to_ipv6 (pr->service_ip_address); fu_redfish_client_set_hostname (self, tmp); } else { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "address format is invalid"); return FALSE; } fu_redfish_client_set_port (self, pr->service_ip_port); } else { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "DHCP address formats not supported (%0x2)", pr->service_ip_assignment_type); return FALSE; } return TRUE; } static gboolean fu_redfish_client_set_smbios_interfaces (FuRedfishClient *self, GBytes *smbios_table, GError **error) { const guint8 *buf; gsize sz = 0; /* check size */ buf = g_bytes_get_data (smbios_table, &sz); if (sz < 0x09) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "SMBIOS entry too small: %" G_GSIZE_FORMAT, sz); return FALSE; } /* check interface type */ if (buf[0x04] != REDFISH_CONTROLLER_INTERFACE_TYPE_NETWORK_HOST) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "only Network Host Interface supported"); return FALSE; } /* check length */ if (buf[0x05] > sz - 0x08) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "interface specific data too large %u > %" G_GSIZE_FORMAT, buf[0x05], sz - 0x08); return FALSE; } /* parse data, for not just for debugging */ if (buf[0x05] > 0) fu_redfish_client_parse_interface_data (&buf[0x06], buf[0x05]); /* parse protocol records */ for (guint8 i = 0x07 + buf[0x05]; i < sz - 1; i++) { guint8 protocol_id = buf[i]; guint8 protocol_sz = buf[i+1]; if (protocol_sz > sz - buf[0x05] + 0x07) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "protocol length too large"); return FALSE; } if (protocol_id == REDFISH_PROTOCOL_REDFISH_OVER_IP) { if (!fu_redfish_client_parse_protocol_data (self, &buf[i+2], protocol_sz, error)) return FALSE; } else { g_debug ("ignoring unsupported protocol ID %02x", protocol_id); } i += protocol_sz - 1; } return TRUE; } gboolean fu_redfish_client_update (FuRedfishClient *self, FuDevice *device, GBytes *blob_fw, GError **error) { FwupdRelease *release; g_autofree gchar *filename = NULL; guint status_code; g_autoptr(SoupMessage) msg = NULL; g_autoptr(SoupURI) uri = NULL; g_autoptr(SoupMultipart) multipart = NULL; g_autoptr(SoupBuffer) buffer = NULL; g_autofree gchar *uri_str = NULL; /* Get the update version */ release = fwupd_device_get_release_default (FWUPD_DEVICE (device)); if (release != NULL) { filename = g_strdup_printf ("%s-%s.bin", fu_device_get_name (device), fwupd_release_get_version (release)); } else { filename = g_strdup_printf ("%s.bin", fu_device_get_name (device)); } /* create URI */ uri = soup_uri_new (NULL); soup_uri_set_scheme (uri, self->use_https ? "https" : "http"); soup_uri_set_path (uri, self->push_uri_path); soup_uri_set_host (uri, self->hostname); soup_uri_set_port (uri, self->port); uri_str = soup_uri_to_string (uri, FALSE); /* Create the multipart request */ multipart = soup_multipart_new (SOUP_FORM_MIME_TYPE_MULTIPART); buffer = soup_buffer_new (SOUP_MEMORY_COPY, g_bytes_get_data (blob_fw, NULL), g_bytes_get_size (blob_fw)); soup_multipart_append_form_file (multipart, filename, filename, "application/octet-stream", buffer); msg = soup_form_request_new_from_multipart (uri_str, multipart); if (msg == NULL) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "failed to create message for URI %s", uri_str); return FALSE; } fu_redfish_client_set_auth (self, uri, msg); status_code = soup_session_send_message (self->session, msg); if (status_code != SOUP_STATUS_OK) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "failed to upload %s to %s: %s", filename, uri_str, soup_status_get_phrase (status_code)); return FALSE; } return TRUE; } gboolean fu_redfish_client_setup (FuRedfishClient *self, GBytes *smbios_table, GError **error) { JsonNode *node_root; JsonObject *obj_root = NULL; JsonObject *obj_update_service = NULL; const gchar *data_id; const gchar *version = NULL; g_autofree gchar *user_agent = NULL; g_autoptr(GBytes) blob = NULL; g_autoptr(JsonParser) parser = json_parser_new (); /* sanity check */ if (self->port == 0) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INTERNAL, "no port specified"); return FALSE; } if (self->port > 0xffff) { g_set_error (error, FWUPD_ERROR, FWUPD_ERROR_INTERNAL, "invalid port specified: 0x%x", self->port); return FALSE; } /* create the soup session */ user_agent = g_strdup_printf ("%s/%s", PACKAGE_NAME, PACKAGE_VERSION); self->session = soup_session_new_with_options (SOUP_SESSION_USER_AGENT, user_agent, SOUP_SESSION_TIMEOUT, 60, NULL); if (self->session == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INTERNAL, "failed to setup networking"); return FALSE; } if (self->cacheck == FALSE) { g_object_set (G_OBJECT (self->session), SOUP_SESSION_SSL_STRICT, FALSE, NULL); } /* this is optional */ if (smbios_table != NULL) { g_autoptr(GError) error_smbios = NULL; g_autoptr(GError) error_uefi = NULL; if (!fu_redfish_client_set_smbios_interfaces (self, smbios_table, &error_smbios)) { g_debug ("failed to get connection URI automatically: %s", error_smbios->message); } if (!fu_redfish_client_set_uefi_credentials (self, &error_uefi)) { g_debug ("failed to get username and password automatically: %s", error_uefi->message); } } if (self->hostname != NULL) g_debug ("Hostname: %s", self->hostname); if (self->port != 0) g_debug ("Port: %u", self->port); if (self->username != NULL) g_debug ("Username: %s", self->username); if (self->password != NULL) g_debug ("Password: %s", self->password); /* try to connect */ blob = fu_redfish_client_fetch_data (self, "/redfish/v1/", error); if (blob == NULL) return FALSE; /* get the update service */ if (!json_parser_load_from_data (parser, g_bytes_get_data (blob, NULL), (gssize) g_bytes_get_size (blob), error)) { g_prefix_error (error, "failed to parse node: "); return FALSE; } node_root = json_parser_get_root (parser); if (node_root == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no root node"); return FALSE; } obj_root = json_node_get_object (node_root); if (obj_root == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no root object"); return FALSE; } if (json_object_has_member (obj_root, "ServiceVersion")) { version = json_object_get_string_member (obj_root, "ServiceVersion"); } else if (json_object_has_member (obj_root, "RedfishVersion")) { version = json_object_get_string_member (obj_root, "RedfishVersion"); } g_debug ("Version: %s", version); g_debug ("UUID: %s", json_object_get_string_member (obj_root, "UUID")); if (json_object_has_member (obj_root, "UpdateService")) obj_update_service = json_object_get_object_member (obj_root, "UpdateService"); if (obj_update_service == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_NOT_SUPPORTED, "no UpdateService object"); return FALSE; } data_id = json_object_get_string_member (obj_update_service, "@odata.id"); if (data_id == NULL) { g_set_error_literal (error, FWUPD_ERROR, FWUPD_ERROR_INVALID_FILE, "no @odata.id string"); return FALSE; } self->update_uri_path = g_strdup (data_id); return TRUE; } GPtrArray * fu_redfish_client_get_devices (FuRedfishClient *self) { return self->devices; } void fu_redfish_client_set_hostname (FuRedfishClient *self, const gchar *hostname) { g_free (self->hostname); self->hostname = g_strdup (hostname); } void fu_redfish_client_set_port (FuRedfishClient *self, guint port) { self->port = port; } void fu_redfish_client_set_https (FuRedfishClient *self, gboolean use_https) { self->use_https = use_https; } void fu_redfish_client_set_cacheck (FuRedfishClient *self, gboolean cacheck) { self->cacheck = cacheck; } void fu_redfish_client_set_username (FuRedfishClient *self, const gchar *username) { g_free (self->username); self->username = g_strdup (username); } void fu_redfish_client_set_password (FuRedfishClient *self, const gchar *password) { g_free (self->password); self->password = g_strdup (password); } static void fu_redfish_client_finalize (GObject *object) { FuRedfishClient *self = FU_REDFISH_CLIENT (object); if (self->session != NULL) g_object_unref (self->session); g_free (self->update_uri_path); g_free (self->push_uri_path); g_free (self->hostname); g_free (self->username); g_free (self->password); g_ptr_array_unref (self->devices); G_OBJECT_CLASS (fu_redfish_client_parent_class)->finalize (object); } static void fu_redfish_client_class_init (FuRedfishClientClass *klass) { GObjectClass *object_class = G_OBJECT_CLASS (klass); object_class->finalize = fu_redfish_client_finalize; } static void fu_redfish_client_init (FuRedfishClient *self) { self->devices = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref); } FuRedfishClient * fu_redfish_client_new (void) { FuRedfishClient *self; self = g_object_new (REDFISH_TYPE_CLIENT, NULL); return FU_REDFISH_CLIENT (self); } /* vim: set noexpandtab: */