"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Stefan Sperling <stsp@stsp.name>
Subject:
gotwebd login hint ssh fingerprints
To:
gameoftrees@openbsd.org
Date:
Tue, 10 Feb 2026 11:45:56 +0100

Download raw body.

Thread
Allow SSH host key fingerprints to be declared in global config scope and
in per-server config scope. The previously added per-repository setting
added for clone URLs remains, but will by default inherit fingerprints from
global or server scope which should save some copy-pasting in many cases.

Display per-server fingerprints on the login hint error page. This way,
people using gotsh weblogin have something to compare the fingerprint
presented by ssh to. To make this work nicely we have to move the login
hint display logic to the templating mechanism, rather than rendering it
as part of an error message. The error buffer might not be large enough
and we cannot easily render a HTML list of items with it.

ok?

M  gotwebd/auth.c          |    1+  12-
M  gotwebd/gotwebd.conf.5  |   30+   0-
M  gotwebd/gotwebd.h       |    3+   0-
M  gotwebd/pages.tmpl      |   44+   1-
M  gotwebd/parse.y         |  161+   0-
M  include/got_error.h     |    1+   0-
M  lib/error.c             |    1+   0-

7 files changed, 241 insertions(+), 13 deletions(-)

commit - e204978ac484c44dae246e111f67051e791764ce
commit + fb84baa15f90fa91afa12a686763acd5362ea132
blob - 72aab4aace8c3420df9a3caf8563af0f27433489
blob + e71a43b08f78c7bc51cb3c2ef665e4ea6296ac9f
--- gotwebd/auth.c
+++ gotwebd/auth.c
@@ -498,23 +498,12 @@ static const struct got_error *
 login_error_hint(struct request *c)
 {
 	struct server *srv;
-	char msg[512];
-	int ret;
 
 	srv = gotweb_get_server(c->fcgi_params.server_name);
 	if (srv == NULL || srv->login_hint_user[0] == '\0')
 		return got_error(GOT_ERR_LOGIN_FAILED);
 
-	ret = snprintf(msg, sizeof(msg),
-	    "Log in by running: ssh %s%s%s%s@%s \"weblogin %s\"",
-	    srv->login_hint_port[0] ? " -p " : "",
-	    srv->login_hint_port[0] ? srv->login_hint_port : "",
-	    srv->login_hint_port[0] ? " " : "",
-	    srv->login_hint_user, srv->name, srv->name);
-	if (ret == -1 || (size_t)ret >= sizeof(msg))
-		return got_error(GOT_ERR_LOGIN_FAILED);
-
-	return got_error_msg(GOT_ERR_LOGIN_FAILED, msg);
+	return got_error(GOT_ERR_LOGIN_HINT);
 }
 
 static void
blob - 213c5b4be700f2eab0b7e9c32341ac7d848a4123
blob + 217c336226c49c9f1022ec49ccfebd39f848ccf3
--- gotwebd/gotwebd.conf.5
+++ gotwebd/gotwebd.conf.5
@@ -253,6 +253,15 @@ Needed to ensure that the web server can access
 sockets created by
 .Xr gotwebd 8 .
 If not specified, the user www will be used.
+.It Ic ssh_hostkey_ecdsa Ar string
+Set the server's SSH ECDSA host key fingerprint to be displayed beneath
+login hints and beneath clone URLs which use the SSH protocol.
+.It Ic ssh_hostkey_ed25519 Ar string
+Set the server's SSH ED25519 host key fingerprint to be displayed beneath
+login hints and beneath clone URLs which use the SSH protocol.
+.It Ic ssh_hostkey_rsa Ar string
+Set the server's SSH RSA host key fingerprint to be displayed beneath
+login hints and beneath clone URLs which use the SSH protocol.
 .El
 .Pp
 If no
@@ -446,6 +455,21 @@ The
 .Cm chroot
 directive must be used before the server declaration in order to
 take effect.
+.It Ic ssh_hostkey_ecdsa Ar string
+Set the server's SSH ECDSA host key fingerprint to be displayed beneath
+login hints and beneath clone URLs which use the SSH protocol.
+If not set then SSH fingerprints found in the global configuration context
+will be used.
+.It Ic ssh_hostkey_ed25519 Ar string
+Set the server's SSH ED25519 host key fingerprint to be displayed beneath
+login hints and beneath clone URLs which use the SSH protocol.
+If not set then SSH fingerprints found in the global configuration context
+will be used.
+.It Ic ssh_hostkey_rsa Ar string
+Set the server's SSH RSA host key fingerprint to be displayed beneath
+login hints and beneath clone URLs which use the SSH protocol.
+If not set then SSH fingerprints found in the global configuration context
+will be used.
 .It Ic repository Ar name Brq ...
 Set options which apply to a particular repository served by this server.
 .Pp
@@ -501,14 +525,20 @@ This file may contain multiple URLs, one per line.
 Set the server's SSH ECDSA host key fingerprint to be displayed beneath
 the clone URL.
 Should be set when the clone URL uses the SSH protocol.
+If not set then SSH fingerprints found in the server or global configuration
+context will be used.
 .It Ic ssh_hostkey_ed25519 Ar string
 Set the server's SSH ED25519 host key fingerprint to be displayed beneath
 the clone URL.
 Should be set when the clone URL uses the SSH protocol.
+If not set then SSH fingerprints found in the server or global configuration
+context will be used.
 .It Ic ssh_hostkey_rsa Ar string
 Set the server's SSH RSA host key fingerprint to be displayed beneath
 the clone URL.
 Should be set when the clone URL uses the SSH protocol.
+If not set then SSH fingerprints found in the server or global configuration
+context will be used.
 .It Ic description Ar string
 Sets the repository description shown on the repository listing page.
 The
blob - e308c7906f34668208667e5f2742f917d90a6df7
blob + f1f75864d29a9d230bb8305b3676479dbe0f5669
--- gotwebd/gotwebd.h
+++ gotwebd/gotwebd.h
@@ -432,6 +432,7 @@ struct server {
 	char		 name[GOTWEBD_MAXTEXT];
 	char		 htdocs_path[PATH_MAX];
 	char		 gotweb_url_root[MAX_DOCUMENT_URI];
+	char		 sshfp[GOTWEBD_NUM_SSHFP][GOTWEBD_MAX_SSHFP];
 
 	char		 repos_path[PATH_MAX];
 	char		 repos_url_path[MAX_DOCUMENT_URI];
@@ -510,6 +511,8 @@ struct gotwebd {
 	enum gotwebd_auth_config auth_config;
 	struct gotwebd_access_rule_list access_rules;
 
+	char sshfp[GOTWEBD_NUM_SSHFP][GOTWEBD_MAX_SSHFP];
+
 	struct mediatypes mediatypes;
 
 	int		 pack_fds[GOTWEB_PACK_NUM_TEMPFILES];
blob - 620eae4ecfde34d2e50eb56fa4eb94374d73fe0d
blob + 70140054bf30861e3c52bc962d3d7623eabb13fd
--- gotwebd/pages.tmpl
+++ gotwebd/pages.tmpl
@@ -271,10 +271,53 @@ nextsep(char *s, char **t)
 {!
 	struct request		*c = tp->tp_arg;
 	struct transport	*t = c->t;
+	struct server		*srv = NULL;
+	const char		*login_hint_user = NULL;
+	const char		*login_hint_port = NULL;
+
+	if (t->error && t->error->code == GOT_ERR_LOGIN_HINT) {
+		srv = gotweb_get_server(c->fcgi_params.server_name);
+		if (srv && srv->login_hint_user[0] != '\0') {
+			login_hint_user = srv->login_hint_user;
+			if (srv->login_hint_port[0] != '\0')
+				login_hint_port = srv->login_hint_port;
+		}
+	}
 !}
 <div id="err_content">
   {{ if t->error }}
-    {{ t->error->msg }}
+    {{ if t->error->code == GOT_ERR_LOGIN_HINT && login_hint_user }}
+      Log in by running: 
+      {{ if login_hint_port }}
+	ssh -p {{ login_hint_port }} {{" "}} {{ login_hint_user }}@{{ srv->name }} {{" "}} "weblogin {{ srv->name }}"
+	
+      {{ else }}
+	ssh {{ login_hint_user }}@{{ srv->name }} {{" "}} "weblogin {{ srv->name }}"
+      {{ end }}
+      {{ if srv->sshfp[GOTWEBD_SSHFP_ECDSA][0] != '\0' ||
+          srv->sshfp[GOTWEBD_SSHFP_ED25519][0] != '\0' ||
+          srv->sshfp[GOTWEBD_SSHFP_RSA][0] != '\0' }}
+	{{ if login_hint_port }}
+	  <p>The SSH host key fingerprints of {{ srv->name }}:{{ login_hint_port }} {{" "}} are:</p>
+	{{ else }}
+	  <p>The SSH host key fingerprints of {{ srv->name }} {{" "}} are:</p>
+	{{ end }}
+	<dl>
+        {{ if srv->sshfp[GOTWEBD_SSHFP_ECDSA][0] != '\0' }}
+          <dt>ECDSA</dt><dd><pre class="clone-url">{{ srv->sshfp[GOTWEBD_SSHFP_ECDSA] }}</pre></dd>
+        {{ end }}
+        {{ if srv->sshfp[GOTWEBD_SSHFP_ED25519][0] != '\0' }}
+          <dt>ED25519</dt><dd><pre class="clone-url">{{ srv->sshfp[GOTWEBD_SSHFP_ED25519] }}</pre></dd>
+        {{ end }}
+        {{ if srv->sshfp[GOTWEBD_SSHFP_RSA][0] != '\0' }}
+          <dt>RSA</dt><dd><pre class="clone-url">{{ srv->sshfp[GOTWEBD_SSHFP_RSA] }}</pre></dd>
+        {{ end }}
+	</dl>
+      {{ end }}
+
+    {{ else }}
+      {{ t->error->msg }}
+    {{ end }}
   {{ else }}
     See daemon logs for details
   {{ end }}
blob - 06d0fa8ac5221e0906bb1adb03a35e49c67c6f35
blob + e356cea29b105402010bc95aa09710adfd5c96ef
--- gotwebd/parse.y
+++ gotwebd/parse.y
@@ -496,6 +496,72 @@ main		: PREFORK NUMBER {
 			free(h);
 			free($3);
 		}
+		| SSH_HOSTKEY_ECDSA STRING {
+			int i = GOTWEBD_SSHFP_ECDSA;
+
+			if (*$2 == '\0') {
+				yyerror("ssh host key fingerprint cannot be "
+				    "an empty string");
+				free($2);
+				YYERROR;
+			}
+
+			if (strlcpy(gotwebd->sshfp[i], $2,
+			    sizeof(gotwebd->sshfp[i])) >=
+			    sizeof(gotwebd->sshfp[i])) {
+				yyerror("ssh host key fingerprint too long, "
+				    "exceeds " "%zd bytes: %s",
+				    sizeof(gotwebd->sshfp[i]), $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
+		| SSH_HOSTKEY_ED25519 STRING {
+			int i = GOTWEBD_SSHFP_ED25519;
+
+			if (*$2 == '\0') {
+				yyerror("ssh host key fingerprint cannot be "
+				    "an empty string");
+				free($2);
+				YYERROR;
+			}
+
+			if (strlcpy(gotwebd->sshfp[i], $2,
+			    sizeof(gotwebd->sshfp[i])) >=
+			    sizeof(gotwebd->sshfp[i])) {
+				yyerror("ssh host key fingerprint too long, "
+				    "exceeds " "%zd bytes: %s",
+				    sizeof(gotwebd->sshfp[i]), $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
+		| SSH_HOSTKEY_RSA STRING {
+			int i = GOTWEBD_SSHFP_RSA;
+
+			if (*$2 == '\0') {
+				yyerror("ssh host key fingerprint cannot be "
+				    "an empty string");
+				free($2);
+				YYERROR;
+			}
+
+			if (strlcpy(gotwebd->sshfp[i], $2,
+			    sizeof(gotwebd->sshfp[i])) >=
+			    sizeof(gotwebd->sshfp[i])) {
+				yyerror("ssh host key fingerprint too long, "
+				    "exceeds " "%zd bytes: %s",
+				    sizeof(gotwebd->sshfp[i]), $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
 		;
 
 server		: SERVER STRING {
@@ -797,6 +863,72 @@ serveropts1	: REPOS_PATH STRING {
 
 			free($2);
 		}
+		| SSH_HOSTKEY_ECDSA STRING {
+			int i = GOTWEBD_SSHFP_ECDSA;
+
+			if (*$2 == '\0') {
+				yyerror("ssh host key fingerprint cannot be "
+				    "an empty string");
+				free($2);
+				YYERROR;
+			}
+
+			if (strlcpy(new_srv->sshfp[i], $2,
+			    sizeof(new_srv->sshfp[i])) >=
+			    sizeof(new_srv->sshfp[i])) {
+				yyerror("ssh host key fingerprint too long, "
+				    "exceeds " "%zd bytes: %s",
+				    sizeof(new_srv->sshfp[i]), $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
+		| SSH_HOSTKEY_ED25519 STRING {
+			int i = GOTWEBD_SSHFP_ED25519;
+
+			if (*$2 == '\0') {
+				yyerror("ssh host key fingerprint cannot be "
+				    "an empty string");
+				free($2);
+				YYERROR;
+			}
+
+			if (strlcpy(new_srv->sshfp[i], $2,
+			    sizeof(new_srv->sshfp[i])) >=
+			    sizeof(new_srv->sshfp[i])) {
+				yyerror("ssh host key fingerprint too long, "
+				    "exceeds " "%zd bytes: %s",
+				    sizeof(new_srv->sshfp[i]), $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
+		| SSH_HOSTKEY_RSA STRING {
+			int i = GOTWEBD_SSHFP_RSA;
+
+			if (*$2 == '\0') {
+				yyerror("ssh host key fingerprint cannot be "
+				    "an empty string");
+				free($2);
+				YYERROR;
+			}
+
+			if (strlcpy(new_srv->sshfp[i], $2,
+			    sizeof(new_srv->sshfp[i])) >=
+			    sizeof(new_srv->sshfp[i])) {
+				yyerror("ssh host key fingerprint too long, "
+				    "exceeds " "%zd bytes: %s",
+				    sizeof(new_srv->sshfp[i]), $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
 		| repository
 		| website
 		;
@@ -1581,6 +1713,7 @@ parse_config(const char *filename, struct gotwebd *env
 	struct sym *sym, *next;
 	struct server *srv;
 	struct gotwebd_repo *repo;
+	size_t i;
 
 	if (config_init(env) == -1)
 		fatalx("failed to initialize configuration");
@@ -1764,6 +1897,18 @@ parse_config(const char *filename, struct gotwebd *env
 
 	/* Inherit implicit configuration from parent scope. */
 	TAILQ_FOREACH(srv, &env->servers, entry) {
+		for (i = 0; i < GOTWEBD_NUM_SSHFP; i++) {
+			if (srv->sshfp[i][0] == '\0' &&
+			    env->sshfp[i][0] != '\0' &&
+			    strlcpy(srv->sshfp[i], env->sshfp[i],
+			    sizeof(srv->sshfp[i])) >= sizeof(srv->sshfp[i])) {
+				fprintf(stderr, "ssh host key fingerprint too "
+				    "long, exceeds %zd bytes: %s",
+				    sizeof(srv->sshfp[i]), env->sshfp[i]);
+				return -1;
+			}
+		}
+
 		if (srv->auth_config == 0)
 			srv->auth_config = env->auth_config;
 		TAILQ_FOREACH(repo, &srv->repos, entry) {
@@ -1771,6 +1916,22 @@ parse_config(const char *filename, struct gotwebd *env
 				repo->auth_config = srv->auth_config;
 			if (repo->hidden == -1)
 				repo->hidden = srv->hide_repositories;
+
+			for (i = 0; i < GOTWEBD_NUM_SSHFP; i++) {
+				if (repo->clone_url_hostkey[i][0] == '\0' &&
+				    srv->sshfp[i][0] != '\0' &&
+				    strlcpy(repo->clone_url_hostkey[i],
+				    srv->sshfp[i],
+				    sizeof(repo->clone_url_hostkey[i])) >=
+				    sizeof(repo->clone_url_hostkey[i])) {
+					fprintf(stderr, "ssh host key "
+					    "fingerprint too long, exceeds "
+					    "%zd bytes: %s",
+					    sizeof(repo->clone_url_hostkey[i]),
+					    srv->sshfp[i]);
+					return -1;
+				}
+			}
 		}
 
 		if (srv->login_hint_user[0] == '\0') {
blob - 301dcaa124592fd188562af5f52a713b9138d4b9
blob + b8d5ef8d431f54e656e69510321dd0a045407136
--- include/got_error.h
+++ include/got_error.h
@@ -202,6 +202,7 @@
 #define GOT_ERR_NOT_FOUND	194
 #define GOT_ERR_MEDIA_TYPE	195
 #define GOT_ERR_LOGOUT_FAILED	196
+#define GOT_ERR_LOGIN_HINT	197
 
 struct got_error {
         int code;
blob - dcca96bf17d67b78004204c30d6fdf6ecd2153f5
blob + a4bb8be92d8391f5e90a4ce6d806655bf7f55f2d
--- lib/error.c
+++ lib/error.c
@@ -253,6 +253,7 @@ static const struct got_error got_errors[] = {
 	{ GOT_ERR_NOT_FOUND, "not found" },
 	{ GOT_ERR_MEDIA_TYPE,	"malformed media type" },
 	{ GOT_ERR_LOGOUT_FAILED, "logout failed" },
+	{ GOT_ERR_LOGIN_HINT, "login failed, see hint" },
 };
 
 static struct got_custom_error {