diff --git a/.gitignore b/.gitignore index 9f119e5..9c43172 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -parse-ssh +parse-ssh-cl ## # From github/gitignore C.gitignore diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d6fd041 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +CC=gcc +CFLAGS=-I. +DEPS = utils.h +OBJ = utils.o main.o + +%.o: %.c $(DEPS) + $(CC) -c -o $@ $< $(CFLAGS) + +parse-ssh-cl: $(OBJ) + $(CC) -o $@ $^ $(CFLAGS) + +.PHONY: clean + +clean: + rm -f *.o *~ core *~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7ec33b --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# SSH Command Line Parser + +## What. + +This program is called exactly like the `ssh` binary from OpenSSH, except +instead of opening a remote shell it will print the username (if specified), +host, and port (if specified) given on the command line. + +```bash +./parse-ssh-cl -J bastion -X estelle@myhost:1234 +User: estelle +Host: myhost +Port: 1234 +Command: + +./parse-ssh-cl -4 -q -L 8080:localhost:8080 app@prod +User: app +Host: prod +Port: +Command: + +./parse-ssh-cl ssh://dev +User: +Host: dev +Port: +Command: + +./parse-ssh-cl host ls -l +User: +Host: host +Port: +Command: ls -l +``` + +## Why? + +Because when I use tmux I want to be able to open a shell on a remote host and +have the window title show the host I'm connected to. Something that can take an +SSH command line string and output the host is an important piece of that +puzzle. + +## How? + +* I looked at the source of OpenSSH (`ssh.c`) and saw two functions + `parse_ssh_uri` and `parse_user_host_port` that seemed like they did what I + wanted so I just copied them over and tried to compile them standalone. + +* GCC yelled at me about all these missing function definitions so I copied + them from around the code base (mostly `misc.c`). + +* If the function wasn't there I Googled around to find out what random header I + needed. + +* I unearthed some heated drama surrounding `strlcpy` so I decided to copy the + implementation from OpenBSD inline rather than link against libbsd. + +* I found [greymd/ssh_opt_parse](https://github.com/greymd/ssh_opt_parse) which + is honestly better than this project in every conceivable way. + +* I copied the `getopt` string so that I could ignore all of the options, ran my + two functions that now work, and boom, command line parsing just how upstream + does it. + +## Do. + +Are you sure you really want to use this thing? Just run `make` in the source +directory and then do whatever with the `parse-ssh-cl` binary it spits out. + +As far as dependencies go just about any distribution's development tools should +be more than enough to build and use. It pretty just uses libc. + +## Never Asked Questions. + +Do you support `-l` and `-p` parsing? Not yet. That's a great idea me. + +## License. + +Pretty much all of this code is straight up copy/pasted from +[OpenSSH](https://github.com/openssh/openssh-portable) and the one function from +[OpenBSD](https://github.com/openbsd/src). After reading the entire LICENSE file +from OpenSSH I have concluded that I have no idea if and how I'm supposed to +give attribution. diff --git a/main.c b/main.c new file mode 100644 index 0000000..c34a185 --- /dev/null +++ b/main.c @@ -0,0 +1,57 @@ +#include "utils.h" + +int +main(int argc, char *argv[]) { + int opt; + while ((opt = getopt(argc, argv, "-1246ab:c:e:fgi:kl:m:no:p:qstvx" + "AB:CD:E:F:GI:J:KL:MNO:PQ:R:S:TVw:W:XYy")) != -1) { + // Stop processing options after the first positional argument and set + // optind back to the location of that argument. + if (opt == 1) { + optind -= 1; + break; + } + } + + if (optind >= argc) { + fprintf(stderr, "No destination found.\n"); + return 1; + } + + char *destination = argv[optind]; + char *user; + char *host; + int port = -1; + + if (parse_ssh_uri(destination, &user, &host, &port) == 0 + || parse_user_host_port(destination, &user, &host, &port) == 0) { + + if (user != NULL) { + printf("User: %s\n", user); + } else { + printf("User:\n"); + } + + printf("Host: %s\n", host); + + if (port != -1) { + printf("Port: %d\n", port); + } else { + printf("Port:\n"); + } + + free(user); + free(host); + + printf("Command:"); + for (int i = ++optind; i < argc; i++) { + printf(" %s", argv[i]); + } + printf("\n"); + } else { + fprintf(stderr, "Could not parse destination host.\n"); + return 1; + } + + return 0; +} diff --git a/utils.c b/utils.c new file mode 100644 index 0000000..d2e778d --- /dev/null +++ b/utils.c @@ -0,0 +1,454 @@ +#include "utils.h" + +long long +strtonum(const char *numstr, long long minval, long long maxval, + const char **errstrp) +{ + long long ll = 0; + char *ep; + int error = 0; + struct errval { + const char *errstr; + int err; + } ev[4] = { + { NULL, 0 }, + { "invalid", EINVAL }, + { "too small", ERANGE }, + { "too large", ERANGE }, + }; + + ev[0].err = errno; + errno = 0; + if (minval > maxval) + error = INVALID; + else { + ll = strtoll(numstr, &ep, 10); + if (numstr == ep || *ep != '\0') + error = INVALID; + else if ((ll == LLONG_MIN && errno == ERANGE) || ll < minval) + error = TOOSMALL; + else if ((ll == LLONG_MAX && errno == ERANGE) || ll > maxval) + error = TOOLARGE; + } + if (errstrp != NULL) + *errstrp = ev[error].errstr; + errno = ev[error].err; + if (error) + ll = 0; + + return (ll); +} + +void * +xmalloc(size_t size) +{ + void *ptr; + + if (size == 0) + fprintf(stderr, "xmalloc: zero size"); + ptr = malloc(size); + if (ptr == NULL) + fprintf(stderr, "xmalloc: out of memory (allocating %zu bytes)", size); + return ptr; +} + +/* + * Converts a two-byte hex string to decimal. + * Returns the decimal value or -1 for invalid input. + */ +static int +hexchar(const char *s) +{ + unsigned char result[2]; + int i; + + for (i = 0; i < 2; i++) { + if (s[i] >= '0' && s[i] <= '9') + result[i] = (unsigned char)(s[i] - '0'); + else if (s[i] >= 'a' && s[i] <= 'f') + result[i] = (unsigned char)(s[i] - 'a') + 10; + else if (s[i] >= 'A' && s[i] <= 'F') + result[i] = (unsigned char)(s[i] - 'A') + 10; + else + return -1; + } + return (result[0] << 4) | result[1]; +} + +/* + * Convert ASCII string to TCP/IP port number. + * Port must be >=0 and <=65535. + * Return -1 if invalid. + */ +int +a2port(const char *s) +{ + struct servent *se; + long long port; + const char *errstr; + + port = strtonum(s, 0, 65535, &errstr); + if (errstr == NULL) + return (int)port; + if ((se = getservbyname(s, "tcp")) != NULL) + return ntohs(se->s_port); + return -1; +} + +/* + * Decode an url-encoded string. + * Returns a newly allocated string on success or NULL on failure. + */ +static char * +urldecode(const char *src) +{ + char *ret, *dst; + int ch; + + ret = xmalloc(strlen(src) + 1); + for (dst = ret; *src != '\0'; src++) { + switch (*src) { + case '+': + *dst++ = ' '; + break; + case '%': + if (!isxdigit((unsigned char)src[1]) || + !isxdigit((unsigned char)src[2]) || + (ch = hexchar(src + 1)) == -1) { + free(ret); + return NULL; + } + *dst++ = ch; + src += 2; + break; + default: + *dst++ = *src; + break; + } + } + *dst = '\0'; + + return ret; +} + +/* + * Search for next delimiter between hostnames/addresses and ports. + * Argument may be modified (for termination). + * Returns *cp if parsing succeeds. + * *cp is set to the start of the next field, if one was found. + * The delimiter char, if present, is stored in delim. + * If this is the last field, *cp is set to NULL. + */ +char * +hpdelim2(char **cp, char *delim) +{ + char *s, *old; + + if (cp == NULL || *cp == NULL) + return NULL; + + old = s = *cp; + if (*s == '[') { + if ((s = strchr(s, ']')) == NULL) + return NULL; + else + s++; + } else if ((s = strpbrk(s, ":/")) == NULL) + s = *cp + strlen(*cp); /* skip to end (see first case below) */ + + switch (*s) { + case '\0': + *cp = NULL; /* no more fields*/ + break; + + case ':': + case '/': + if (delim != NULL) + *delim = *s; + *s = '\0'; /* terminate */ + *cp = s + 1; + break; + + default: + return NULL; + } + + return old; +} + +/* + * Check and optionally lowercase a domain name, also removes trailing '.' + * Returns 1 on success and 0 on failure, storing an error message in errstr. + */ +int +valid_domain(char *name, int makelower, const char **errstr) +{ + size_t i, l = strlen(name); + u_char c, last = '\0'; + static char errbuf[256]; + + if (l == 0) { + strlcpy(errbuf, "empty domain name", sizeof(errbuf)); + goto bad; + } + if (!isalpha((u_char)name[0]) && !isdigit((u_char)name[0])) { + snprintf(errbuf, sizeof(errbuf), "domain name \"%.100s\" " + "starts with invalid character", name); + goto bad; + } + for (i = 0; i < l; i++) { + c = tolower((u_char)name[i]); + if (makelower) + name[i] = (char)c; + if (last == '.' && c == '.') { + snprintf(errbuf, sizeof(errbuf), "domain name " + "\"%.100s\" contains consecutive separators", name); + goto bad; + } + if (c != '.' && c != '-' && !isalnum(c) && + c != '_') /* technically invalid, but common */ { + snprintf(errbuf, sizeof(errbuf), "domain name " + "\"%.100s\" contains invalid characters", name); + goto bad; + } + last = c; + } + if (name[l - 1] == '.') + name[l - 1] = '\0'; + if (errstr != NULL) + *errstr = NULL; + return 1; +bad: + if (errstr != NULL) + *errstr = errbuf; + return 0; +} + + +char * +cleanhostname(char *host) +{ + if (*host == '[' && host[strlen(host) - 1] == ']') { + host[strlen(host) - 1] = '\0'; + return (host + 1); + } else + return host; +} + +char * +xstrdup(const char *str) +{ + size_t len; + char *cp; + + len = strlen(str) + 1; + cp = xmalloc(len); + strlcpy(cp, str, len); + return cp; +} + +/* + * Parse an (scp|ssh|sftp)://[user@]host[:port][/path] URI. + * See https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04 + * Either user or path may be url-encoded (but not host or port). + * Caller must free returned user, host and path. + * Any of the pointer return arguments may be NULL (useful for syntax checking) + * but the scheme must always be specified. + * If user was not specified then *userp will be set to NULL. + * If port was not specified then *portp will be -1. + * If path was not specified then *pathp will be set to NULL. + * Returns 0 on success, 1 if non-uri/wrong scheme, -1 on error/invalid uri. + */ +int +parse_uri(const char *scheme, const char *uri, char **userp, char **hostp, + int *portp, char **pathp) +{ + char *uridup, *cp, *tmp, ch; + char *user = NULL, *host = NULL, *path = NULL; + int port = -1, ret = -1; + size_t len; + + len = strlen(scheme); + if (strncmp(uri, scheme, len) != 0 || strncmp(uri + len, "://", 3) != 0) + return 1; + uri += len + 3; + + if (userp != NULL) + *userp = NULL; + if (hostp != NULL) + *hostp = NULL; + if (portp != NULL) + *portp = -1; + if (pathp != NULL) + *pathp = NULL; + + uridup = tmp = xstrdup(uri); + + /* Extract optional ssh-info (username + connection params) */ + if ((cp = strchr(tmp, '@')) != NULL) { + char *delim; + + *cp = '\0'; + /* Extract username and connection params */ + if ((delim = strchr(tmp, ';')) != NULL) { + /* Just ignore connection params for now */ + *delim = '\0'; + } + if (*tmp == '\0') { + /* Empty username */ + goto out; + } + if ((user = urldecode(tmp)) == NULL) + goto out; + tmp = cp + 1; + } + + /* Extract mandatory hostname */ + if ((cp = hpdelim2(&tmp, &ch)) == NULL || *cp == '\0') + goto out; + host = xstrdup(cleanhostname(cp)); + if (!valid_domain(host, 0, NULL)) + goto out; + + if (tmp != NULL && *tmp != '\0') { + if (ch == ':') { + /* Convert and verify port. */ + if ((cp = strchr(tmp, '/')) != NULL) + *cp = '\0'; + if ((port = a2port(tmp)) <= 0) + goto out; + tmp = cp ? cp + 1 : NULL; + } + if (tmp != NULL && *tmp != '\0') { + /* Extract optional path */ + if ((path = urldecode(tmp)) == NULL) + goto out; + } + } + + /* Success */ + if (userp != NULL) { + *userp = user; + user = NULL; + } + if (hostp != NULL) { + *hostp = host; + host = NULL; + } + if (portp != NULL) + *portp = port; + if (pathp != NULL) { + *pathp = path; + path = NULL; + } + ret = 0; + out: + free(uridup); + free(user); + free(host); + free(path); + return ret; +} + +int +parse_ssh_uri(const char *uri, char **userp, char **hostp, int *portp) +{ + char *path; + int r; + + r = parse_uri("ssh", uri, userp, hostp, portp, &path); + if (r == 0 && path != NULL) + r = -1; /* path not allowed */ + return r; +} + +/* + * Parse a [user@]host[:port] string. + * Caller must free returned user and host. + * Any of the pointer return arguments may be NULL (useful for syntax checking). + * If user was not specified then *userp will be set to NULL. + * If port was not specified then *portp will be -1. + * Returns 0 on success, -1 on failure. + */ +int +parse_user_host_port(const char *s, char **userp, char **hostp, int *portp) +{ + char *sdup, *cp, *tmp; + char *user = NULL, *host = NULL; + int port = -1, ret = -1; + + if (userp != NULL) + *userp = NULL; + if (hostp != NULL) + *hostp = NULL; + if (portp != NULL) + *portp = -1; + + if ((sdup = tmp = strdup(s)) == NULL) + return -1; + /* Extract optional username */ + if ((cp = strrchr(tmp, '@')) != NULL) { + *cp = '\0'; + if (*tmp == '\0') + goto out; + if ((user = strdup(tmp)) == NULL) + goto out; + tmp = cp + 1; + } + /* Extract mandatory hostname */ + if ((cp = hpdelim2(&tmp, NULL)) == NULL || *cp == '\0') + goto out; + host = xstrdup(cleanhostname(cp)); + /* Convert and verify optional port */ + if (tmp != NULL && *tmp != '\0') { + if ((port = a2port(tmp)) <= 0) + goto out; + } + /* Success */ + if (userp != NULL) { + *userp = user; + user = NULL; + } + if (hostp != NULL) { + *hostp = host; + host = NULL; + } + if (portp != NULL) + *portp = port; + ret = 0; + out: + free(sdup); + free(user); + free(host); + return ret; +} + +/* + * Copy string src to buffer dst of size dsize. At most dsize-1 + * chars will be copied. Always NUL terminates (unless dsize == 0). + * Returns strlen(src); if retval >= dsize, truncation occurred. + */ +size_t +strlcpy(char * __restrict dst, const char * __restrict src, size_t dsize) +{ + const char *osrc = src; + size_t nleft = dsize; + + /* Copy as many bytes as will fit. */ + if (nleft != 0) { + while (--nleft != 0) { + if ((*dst++ = *src++) == '\0') + break; + } + } + + /* Not enough room in dst, add NUL and traverse rest of src. */ + if (nleft == 0) { + if (dsize != 0) + *dst = '\0'; /* NUL-terminate dst */ + while (*src++) + ; + } + + return(src - osrc - 1); /* count does not include NUL */ +} diff --git a/utils.h b/utils.h new file mode 100644 index 0000000..38ccd4e --- /dev/null +++ b/utils.h @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#define INVALID 1 +#define TOOSMALL 2 +#define TOOLARGE 3 + +long long +strtonum(const char *numstr, long long minval, long long maxval, + const char **errstrp); + +void * +xmalloc(size_t size); + +static int +hexchar(const char *s); + +int +a2port(const char *s); + +static char * +urldecode(const char *src); + +char * +hpdelim2(char **cp, char *delim); + +int +valid_domain(char *name, int makelower, const char **errstr); + +char * +cleanhostname(char *host); + +char * +xstrdup(const char *str); + +int +parse_uri(const char *scheme, const char *uri, char **userp, char **hostp, + int *portp, char **pathp); + +int +parse_ssh_uri(const char *uri, char **userp, char **hostp, int *portp); + +int +parse_user_host_port(const char *s, char **userp, char **hostp, int *portp); + +size_t +strlcpy(char * __restrict dst, const char * __restrict src, size_t dsize);