| /* |
| * DNS Resolver Module User-space Helper for AFSDB records |
| * |
| * Copyright (C) Wang Lei (wang840925@gmail.com) 2010 |
| * Authors: Wang Lei (wang840925@gmail.com) |
| * David Howells (dhowells@redhat.com) |
| * |
| * This is a userspace tool for querying AFSDB RR records in the DNS on behalf |
| * of the kernel, and converting the VL server addresses to IPv4 format so that |
| * they can be used by the kAFS filesystem. |
| * |
| * Compile with: |
| * |
| * cc -o key.dns_resolver key.dns_resolver.c -lresolv -lkeyutils |
| * |
| * As some function like res_init() should use the static liberary, which is a |
| * bug of libresolv, that is the reason for cifs.upcall to reimplement. |
| * |
| * To use this program, you must tell /sbin/request-key how to invoke it. You |
| * need to have the keyutils package installed and something like the following |
| * lines added to your /etc/request-key.conf file: |
| * |
| * #OP TYPE DESCRIPTION CALLOUT INFO PROGRAM ARG1 ARG2 ARG3 ... |
| * ====== ============ =========== ============ ========================== |
| * create dns_resolver afsdb:* * /sbin/key.dns_resolver %k |
| * |
| * |
| * This program is free software; you can redistribute it and/or modify |
| * it under the terms of the GNU General Public License as published by |
| * the Free Software Foundation; either version 2 of the License, or |
| * (at your option) any later version. |
| * |
| * This program 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 General Public License for more details. |
| * You should have received a copy of the GNU General Public License |
| * along with this program; if not, write to the Free Software |
| * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
| */ |
| #define _GNU_SOURCE |
| #include <netinet/in.h> |
| #include <arpa/nameser.h> |
| #include <arpa/inet.h> |
| #include <resolv.h> |
| #include <getopt.h> |
| #include <sys/types.h> |
| #include <sys/socket.h> |
| #include <netdb.h> |
| #include <syslog.h> |
| #include <errno.h> |
| #include <string.h> |
| #include <stdio.h> |
| #include <stdarg.h> |
| #include <keyutils.h> |
| #include <stdlib.h> |
| #include <unistd.h> |
| #include <time.h> |
| |
| static const char *DNS_PARSE_VERSION = "1.0"; |
| static const char prog[] = "key.dns_resolver"; |
| static const char key_type[] = "dns_resolver"; |
| static const char a_query_type[] = "a"; |
| static const char aaaa_query_type[] = "aaaa"; |
| static const char afsdb_query_type[] = "afsdb"; |
| static key_serial_t key; |
| static int verbose; |
| static int debug_mode; |
| |
| |
| #define MAX_VLS 15 /* Max Volume Location Servers Per-Cell */ |
| #define DNS_EXPIRY_PREFIX "expiry_time=" |
| #define DNS_EXPIRY_TIME_LEN 10 /* 2^32 - 1 = 4294967295 */ |
| #define AFSDB_MAX_DATA_LEN \ |
| ((MAX_VLS * (INET6_ADDRSTRLEN + 1)) + sizeof(DNS_EXPIRY_PREFIX) + \ |
| DNS_EXPIRY_TIME_LEN + 1 /* '#'*/ + 1 /* end 0 */) |
| |
| #define INET_IP4_ONLY 0x1 |
| #define INET_IP6_ONLY 0x2 |
| #define INET_ALL 0xFF |
| #define ONE_ADDR_ONLY 0x100 |
| #define LIST_MULTIPLE_ADDRS 0x200 |
| |
| /* |
| * segmental payload |
| */ |
| #define N_PAYLOAD 256 |
| struct iovec payload[N_PAYLOAD]; |
| int payload_index; |
| |
| /* |
| * Print an error to stderr or the syslog, negate the key being created and |
| * exit |
| */ |
| static __attribute__((format(printf, 1, 2), noreturn)) |
| void error(const char *fmt, ...) |
| { |
| va_list va; |
| |
| va_start(va, fmt); |
| if (isatty(2)) { |
| vfprintf(stderr, fmt, va); |
| fputc('\n', stderr); |
| } else { |
| vsyslog(LOG_ERR, fmt, va); |
| } |
| va_end(va); |
| |
| /* |
| * on error, negatively instantiate the key ourselves so that we can |
| * make sure the kernel doesn't hang it off of a searchable keyring |
| * and interfere with the next attempt to instantiate the key. |
| */ |
| if (!debug_mode) |
| keyctl_negate(key, 1, KEY_REQKEY_DEFL_DEFAULT); |
| |
| exit(1); |
| } |
| |
| #define error(FMT, ...) error("Error: " FMT, ##__VA_ARGS__); |
| |
| /* |
| * Just print an error to stderr or the syslog |
| */ |
| static __attribute__((format(printf, 1, 2))) |
| void _error(const char *fmt, ...) |
| { |
| va_list va; |
| |
| va_start(va, fmt); |
| if (isatty(2)) { |
| vfprintf(stderr, fmt, va); |
| fputc('\n', stderr); |
| } else { |
| vsyslog(LOG_ERR, fmt, va); |
| } |
| va_end(va); |
| } |
| |
| /* |
| * Print status information |
| */ |
| static __attribute__((format(printf, 1, 2))) |
| void info(const char *fmt, ...) |
| { |
| va_list va; |
| |
| if (verbose < 1) |
| return; |
| |
| va_start(va, fmt); |
| if (isatty(1)) { |
| fputs("I: ", stdout); |
| vfprintf(stdout, fmt, va); |
| fputc('\n', stdout); |
| } else { |
| vsyslog(LOG_INFO, fmt, va); |
| } |
| va_end(va); |
| } |
| |
| /* |
| * Print a nameserver error and exit |
| */ |
| static const int ns_errno_map[] = { |
| [0] = ECONNREFUSED, |
| [HOST_NOT_FOUND] = ENODATA, |
| [TRY_AGAIN] = EAGAIN, |
| [NO_RECOVERY] = ECONNREFUSED, |
| [NO_DATA] = ENODATA, |
| }; |
| |
| static __attribute__((noreturn)) |
| void nsError(int err, const char *domain) |
| { |
| unsigned timeout = 1 * 60; |
| int ret; |
| |
| if (isatty(2)) |
| fprintf(stderr, "%s: %s.\n", domain, hstrerror(err)); |
| else |
| syslog(LOG_INFO, "%s: %s", domain, hstrerror(err)); |
| |
| if (err >= sizeof(ns_errno_map) / sizeof(ns_errno_map[0])) |
| err = ECONNREFUSED; |
| else |
| err = ns_errno_map[err]; |
| |
| info("Reject the key with error %d", err); |
| |
| if (err == EAGAIN) |
| timeout = 1; |
| else if (err == ECONNREFUSED) |
| timeout = 10; |
| |
| if (!debug_mode) { |
| ret = keyctl_reject(key, timeout, err, KEY_REQKEY_DEFL_DEFAULT); |
| if (ret == -1) |
| error("%s: keyctl_reject: %m", __func__); |
| } |
| exit(0); |
| } |
| |
| /* |
| * Print debugging information |
| */ |
| static __attribute__((format(printf, 1, 2))) |
| void debug(const char *fmt, ...) |
| { |
| va_list va; |
| |
| if (verbose < 2) |
| return; |
| |
| va_start(va, fmt); |
| if (isatty(1)) { |
| fputs("D: ", stdout); |
| vfprintf(stdout, fmt, va); |
| fputc('\n', stdout); |
| } else { |
| vsyslog(LOG_DEBUG, fmt, va); |
| } |
| va_end(va); |
| } |
| |
| /* |
| * Append an address to the payload segment list |
| */ |
| static void append_address_to_payload(char *p, size_t sz) |
| { |
| int loop; |
| |
| debug("append '%*.*s'", (int)sz, (int)sz, p); |
| |
| /* discard duplicates */ |
| for (loop = 0; loop < payload_index; loop++) |
| if (payload[loop].iov_len == sz && |
| memcmp(payload[loop].iov_base, p, sz) == 0) |
| return; |
| |
| if (payload_index != 0) { |
| if (payload_index + 2 > N_PAYLOAD - 1) |
| return; |
| payload[payload_index ].iov_base = ","; |
| payload[payload_index++].iov_len = 1; |
| } else { |
| if (payload_index + 1 > N_PAYLOAD - 1) |
| return; |
| } |
| |
| payload[payload_index ].iov_base = p; |
| payload[payload_index++].iov_len = sz; |
| } |
| |
| /* |
| * Dump the payload when debugging |
| */ |
| static void dump_payload(void) |
| { |
| size_t plen, n; |
| char *buf, *p; |
| int loop; |
| |
| if (debug_mode) |
| verbose = 1; |
| if (verbose < 1) |
| return; |
| |
| plen = 0; |
| for (loop = 0; loop < payload_index; loop++) { |
| n = payload[loop].iov_len; |
| debug("seg[%d]: %zu", loop, n); |
| plen += n; |
| } |
| if (plen == 0) { |
| info("The key instantiation data is empty"); |
| return; |
| } |
| |
| debug("total: %zu", plen); |
| buf = malloc(plen + 1); |
| if (!buf) |
| return; |
| |
| p = buf; |
| for (loop = 0; loop < payload_index; loop++) { |
| n = payload[loop].iov_len; |
| memcpy(p, payload[loop].iov_base, n); |
| p += n; |
| } |
| |
| info("The key instantiation data is '%s'", buf); |
| free(buf); |
| } |
| |
| /* |
| * Perform address resolution on a hostname and add the resulting address as a |
| * string to the list of payload segments. |
| */ |
| static int |
| dns_resolver(const char *server_name, unsigned mask) |
| { |
| struct addrinfo hints, *addr, *ai; |
| size_t slen; |
| char buf[INET6_ADDRSTRLEN + 1], *seg; |
| int ret, len; |
| void *sa; |
| |
| debug("Resolve '%s' with %x", server_name, mask); |
| |
| memset(&hints, 0, sizeof(hints)); |
| switch (mask & INET_ALL) { |
| case INET_IP4_ONLY: hints.ai_family = AF_INET; debug("IPv4"); break; |
| case INET_IP6_ONLY: hints.ai_family = AF_INET6; debug("IPv6"); break; |
| default: break; |
| } |
| |
| /* resolve name to ip */ |
| ret = getaddrinfo(server_name, NULL, &hints, &addr); |
| if (ret) { |
| info("unable to resolve hostname: %s [%s]", |
| server_name, gai_strerror(ret)); |
| return -1; |
| } |
| |
| debug("getaddrinfo = %d", ret); |
| |
| for (ai = addr; ai; ai = ai->ai_next) { |
| debug("RR: %x,%x,%x,%x,%x,%s", |
| ai->ai_flags, ai->ai_family, |
| ai->ai_socktype, ai->ai_protocol, |
| ai->ai_addrlen, ai->ai_canonname); |
| |
| /* convert address to string */ |
| switch (ai->ai_family) { |
| case AF_INET: |
| if (!(mask & INET_IP4_ONLY)) |
| continue; |
| sa = &(((struct sockaddr_in *)ai->ai_addr)->sin_addr); |
| len = INET_ADDRSTRLEN; |
| break; |
| case AF_INET6: |
| if (!(mask & INET_IP6_ONLY)) |
| continue; |
| sa = &(((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr); |
| len = INET6_ADDRSTRLEN; |
| break; |
| default: |
| debug("Address of unknown family %u", addr->ai_family); |
| continue; |
| } |
| |
| if (!inet_ntop(ai->ai_family, sa, buf, len)) |
| error("%s: inet_ntop: %m", __func__); |
| |
| slen = strlen(buf); |
| seg = malloc(slen); |
| if (!seg) |
| error("%s: inet_ntop: %m", __func__); |
| memcpy(seg, buf, slen); |
| append_address_to_payload(seg, slen); |
| if (mask & ONE_ADDR_ONLY) |
| break; |
| } |
| |
| freeaddrinfo(addr); |
| return 0; |
| } |
| |
| /* |
| * |
| */ |
| static void afsdb_hosts_to_addrs(char *vllist[], |
| int *vlsnum, |
| ns_msg handle, |
| ns_sect section, |
| unsigned mask, |
| unsigned long *_ttl) |
| { |
| int rrnum; |
| ns_rr rr; |
| int subtype, i, ret; |
| unsigned int ttl = UINT_MAX, rr_ttl; |
| |
| debug("AFSDB RR count is %d", ns_msg_count(handle, section)); |
| |
| /* Look at all the resource records in this section. */ |
| for (rrnum = 0; rrnum < ns_msg_count(handle, section); rrnum++) { |
| /* Expand the resource record number rrnum into rr. */ |
| if (ns_parserr(&handle, section, rrnum, &rr)) { |
| _error("ns_parserr failed : %m"); |
| continue; |
| } |
| |
| /* We're only interested in AFSDB records */ |
| if (ns_rr_type(rr) == ns_t_afsdb) { |
| vllist[*vlsnum] = malloc(MAXDNAME); |
| if (!vllist[*vlsnum]) |
| error("Out of memory"); |
| |
| subtype = ns_get16(ns_rr_rdata(rr)); |
| |
| /* Expand the name server's domain name */ |
| if (ns_name_uncompress(ns_msg_base(handle), |
| ns_msg_end(handle), |
| ns_rr_rdata(rr) + 2, |
| vllist[*vlsnum], |
| MAXDNAME) < 0) |
| error("ns_name_uncompress failed"); |
| |
| rr_ttl = ns_rr_ttl(rr); |
| if (ttl > rr_ttl) |
| ttl = rr_ttl; |
| |
| /* Check the domain name we've just unpacked and add it to |
| * the list of VL servers if it is not a duplicate. |
| * If it is a duplicate, just ignore it. |
| */ |
| for (i = 0; i < *vlsnum; i++) |
| if (strcasecmp(vllist[i], vllist[*vlsnum]) == 0) |
| goto next_one; |
| |
| /* Turn the hostname into IP addresses */ |
| ret = dns_resolver(vllist[*vlsnum], mask); |
| if (ret) { |
| debug("AFSDB RR can't resolve." |
| "subtype:%d, server name:%s, netmask:%u", |
| subtype, vllist[*vlsnum], mask); |
| goto next_one; |
| } |
| |
| info("AFSDB RR subtype:%d, server name:%s, ip:%*.*s, ttl:%u", |
| subtype, vllist[*vlsnum], |
| (int)payload[payload_index - 1].iov_len, |
| (int)payload[payload_index - 1].iov_len, |
| (char *)payload[payload_index - 1].iov_base, |
| ttl); |
| |
| /* prepare for the next record */ |
| *vlsnum += 1; |
| continue; |
| |
| next_one: |
| free(vllist[*vlsnum]); |
| } |
| } |
| |
| *_ttl = ttl; |
| info("ttl: %u", ttl); |
| } |
| |
| /* |
| * Look up an AFSDB record to get the VL server addresses. |
| * |
| * The callout_info is parsed for request options. For instance, "ipv4" to |
| * request only IPv4 addresses and "ipv6" to request only IPv6 addresses. |
| */ |
| static __attribute__((noreturn)) |
| int dns_query_afsdb(key_serial_t key, const char *cell, char *options) |
| { |
| int ret; |
| char *vllist[MAX_VLS]; /* list of name servers */ |
| int vlsnum = 0; /* number of name servers in list */ |
| unsigned mask = INET_ALL; |
| int response_len; /* buffer length */ |
| ns_msg handle; /* handle for response message */ |
| unsigned long ttl = ULONG_MAX; |
| union { |
| HEADER hdr; |
| u_char buf[NS_PACKETSZ]; |
| } response; /* response buffers */ |
| |
| debug("Get AFSDB RR for cell name:'%s', options:'%s'", cell, options); |
| |
| /* query the dns for an AFSDB resource record */ |
| response_len = res_query(cell, |
| ns_c_in, |
| ns_t_afsdb, |
| response.buf, |
| sizeof(response)); |
| |
| if (response_len < 0) |
| /* negative result */ |
| nsError(h_errno, cell); |
| |
| if (ns_initparse(response.buf, response_len, &handle) < 0) |
| error("ns_initparse: %m"); |
| |
| /* Is the IP address family limited? */ |
| if (strcmp(options, "ipv4") == 0) |
| mask = INET_IP4_ONLY; |
| else if (strcmp(options, "ipv6") == 0) |
| mask = INET_IP6_ONLY; |
| |
| /* look up the hostnames we've obtained to get the actual addresses */ |
| afsdb_hosts_to_addrs(vllist, &vlsnum, handle, ns_s_an, mask, &ttl); |
| |
| info("DNS query AFSDB RR results:%u ttl:%lu", payload_index, ttl); |
| |
| /* set the key's expiry time from the minimum TTL encountered */ |
| if (!debug_mode) { |
| ret = keyctl_set_timeout(key, ttl); |
| if (ret == -1) |
| error("%s: keyctl_set_timeout: %m", __func__); |
| } |
| |
| /* handle a lack of results */ |
| if (payload_index == 0) |
| nsError(NO_DATA, cell); |
| |
| /* must include a NUL char at the end of the payload */ |
| payload[payload_index].iov_base = ""; |
| payload[payload_index++].iov_len = 1; |
| dump_payload(); |
| |
| /* load the key with data key */ |
| if (!debug_mode) { |
| ret = keyctl_instantiate_iov(key, payload, payload_index, 0); |
| if (ret == -1) |
| error("%s: keyctl_instantiate: %m", __func__); |
| } |
| |
| exit(0); |
| } |
| |
| /* |
| * Look up a A and/or AAAA records to get host addresses |
| * |
| * The callout_info is parsed for request options. For instance, "ipv4" to |
| * request only IPv4 addresses, "ipv6" to request only IPv6 addresses and |
| * "list" to get multiple addresses. |
| */ |
| static __attribute__((noreturn)) |
| int dns_query_a_or_aaaa(key_serial_t key, const char *hostname, char *options) |
| { |
| unsigned mask; |
| int ret; |
| |
| debug("Get A/AAAA RR for hostname:'%s', options:'%s'", |
| hostname, options); |
| |
| if (!options[0]) { |
| /* legacy mode */ |
| mask = INET_IP4_ONLY | ONE_ADDR_ONLY; |
| } else { |
| char *key, *val; |
| |
| mask = INET_ALL | ONE_ADDR_ONLY; |
| |
| do { |
| key = options; |
| options = strchr(options, ' '); |
| if (!options) |
| options = key + strlen(key); |
| else |
| *options++ = '\0'; |
| if (!*key) |
| continue; |
| if (strchr(key, ',')) |
| error("Option name '%s' contains a comma", key); |
| |
| val = strchr(key, '='); |
| if (val) |
| *val++ = '\0'; |
| |
| debug("Opt %s", key); |
| |
| if (strcmp(key, "ipv4") == 0) { |
| mask &= ~INET_ALL; |
| mask |= INET_IP4_ONLY; |
| } else if (strcmp(key, "ipv6") == 0) { |
| mask &= ~INET_ALL; |
| mask |= INET_IP6_ONLY; |
| } else if (strcmp(key, "list") == 0) { |
| mask &= ~ONE_ADDR_ONLY; |
| mask |= LIST_MULTIPLE_ADDRS; |
| } |
| |
| } while (*options); |
| } |
| |
| /* Turn the hostname into IP addresses */ |
| ret = dns_resolver(hostname, mask); |
| if (ret) |
| nsError(NO_DATA, hostname); |
| |
| /* handle a lack of results */ |
| if (payload_index == 0) |
| nsError(NO_DATA, hostname); |
| |
| /* must include a NUL char at the end of the payload */ |
| payload[payload_index].iov_base = ""; |
| payload[payload_index++].iov_len = 1; |
| dump_payload(); |
| |
| /* load the key with data key */ |
| if (!debug_mode) { |
| ret = keyctl_instantiate_iov(key, payload, payload_index, 0); |
| if (ret == -1) |
| error("%s: keyctl_instantiate: %m", __func__); |
| } |
| |
| exit(0); |
| } |
| |
| /* |
| * Print usage details, |
| */ |
| static __attribute__((noreturn)) |
| void usage(void) |
| { |
| if (isatty(2)) { |
| fprintf(stderr, |
| "Usage: %s [-vv] key_serial\n", |
| prog); |
| fprintf(stderr, |
| "Usage: %s -D [-vv] <desc> <calloutinfo>\n", |
| prog); |
| } else { |
| info("Usage: %s [-vv] key_serial", prog); |
| } |
| if (!debug_mode) |
| keyctl_negate(key, 1, KEY_REQKEY_DEFL_DEFAULT); |
| exit(2); |
| } |
| |
| const struct option long_options[] = { |
| { "debug", 0, NULL, 'D' }, |
| { "verbose", 0, NULL, 'v' }, |
| { "version", 0, NULL, 'V' }, |
| { NULL, 0, NULL, 0 } |
| }; |
| |
| /* |
| * |
| */ |
| int main(int argc, char *argv[]) |
| { |
| int ktlen, qtlen, ret; |
| char *keyend, *p; |
| char *callout_info = NULL; |
| char *buf = NULL, *name; |
| |
| openlog(prog, 0, LOG_DAEMON); |
| |
| while ((ret = getopt_long(argc, argv, "vD", long_options, NULL)) != -1) { |
| switch (ret) { |
| case 'D': |
| debug_mode = 1; |
| continue; |
| case 'V': |
| printf("version: %s\n", DNS_PARSE_VERSION); |
| exit(0); |
| case 'v': |
| verbose++; |
| continue; |
| default: |
| if (!isatty(2)) |
| syslog(LOG_ERR, "unknown option: %c", ret); |
| usage(); |
| } |
| } |
| |
| argc -= optind; |
| argv += optind; |
| |
| if (!debug_mode) { |
| if (argc != 1) |
| usage(); |
| |
| /* get the key ID */ |
| errno = 0; |
| key = strtol(*argv, NULL, 10); |
| if (errno != 0) |
| error("Invalid key ID format: %m"); |
| |
| /* get the key description (of the form "x;x;x;x;<query_type>:<name>") */ |
| if (!buf) { |
| ret = keyctl_describe_alloc(key, &buf); |
| if (ret == -1) |
| error("keyctl_describe_alloc failed: %m"); |
| } |
| |
| /* get the callout_info (which can supply options) */ |
| if (!callout_info) { |
| ret = keyctl_read_alloc(KEY_SPEC_REQKEY_AUTH_KEY, |
| (void **)&callout_info); |
| if (ret == -1) |
| error("Invalid key callout_info read: %m"); |
| } |
| } else { |
| if (argc != 2) |
| usage(); |
| |
| ret = asprintf(&buf, "%s;-1;-1;0;%s", key_type, argv[0]); |
| if (ret < 0) |
| error("Error %m"); |
| callout_info = argv[1]; |
| } |
| |
| ret = 1; |
| info("Key description: '%s'", buf); |
| info("Callout info: '%s'", callout_info); |
| |
| p = strchr(buf, ';'); |
| if (!p) |
| error("Badly formatted key description '%s'", buf); |
| ktlen = p - buf; |
| |
| /* make sure it's the type we are expecting */ |
| if (ktlen != sizeof(key_type) - 1 || |
| memcmp(buf, key_type, ktlen) != 0) |
| error("Key type is not supported: '%*.*s'", ktlen, ktlen, buf); |
| |
| keyend = buf + ktlen + 1; |
| |
| /* the actual key description follows the last semicolon */ |
| keyend = rindex(keyend, ';'); |
| if (!keyend) |
| error("Invalid key description: %s", buf); |
| keyend++; |
| |
| name = index(keyend, ':'); |
| if (!name) |
| dns_query_a_or_aaaa(key, keyend, callout_info); |
| |
| qtlen = name - keyend; |
| name++; |
| |
| if ((qtlen == sizeof(a_query_type) - 1 && |
| memcmp(keyend, a_query_type, sizeof(a_query_type) - 1) == 0) || |
| (qtlen == sizeof(aaaa_query_type) - 1 && |
| memcmp(keyend, aaaa_query_type, sizeof(aaaa_query_type) - 1) == 0) |
| ) { |
| info("Do DNS query of A/AAAA type for:'%s' mask:'%s'", |
| name, callout_info); |
| dns_query_a_or_aaaa(key, name, callout_info); |
| } |
| |
| if (qtlen == sizeof(afsdb_query_type) - 1 && |
| memcmp(keyend, afsdb_query_type, sizeof(afsdb_query_type) - 1) == 0 |
| ) { |
| info("Do DNS query of AFSDB type for:'%s' mask:'%s'", |
| name, callout_info); |
| dns_query_afsdb(key, name, callout_info); |
| } |
| |
| error("Query type: \"%*.*s\" is not supported", qtlen, qtlen, keyend); |
| } |