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

From:
Stefan Sperling <stsp@stsp.name>
Subject:
make gotwebd serve its own static files
To:
gameoftrees@openbsd.org
Date:
Mon, 6 Oct 2025 10:38:07 +0200

Download raw body.

Thread
Because gotwebd's FastCGI socket can be reached over TCP, the web server
and gotwebd do not necessarily need to run on the same machine.

However, pages won't render properly unless the web server provides
the static files needed to render the page (.css, .png, etc.).

This patch adds support to gotwebd for serving these files directly,
and updates the documentation accordingly. This fixes FastCGI over
TCP to gotwebd running on remote machines, and also makes local setups
simpler since we no longer need to fiddle with web server location
match syntax to get the files served correctly.


ok?


make gotwebd serve its own static files if not they are not served by httpd

This makes it possible to forward FastCGI requests to a remote gotwebd over
TCP without requiring the local webserver to host gotwebd's static files.

M  gotwebd/auth.c            |   13+   0-
M  gotwebd/config.c          |    2+   0-
M  gotwebd/gotweb.c          |  153+   2-
M  gotwebd/gotwebd.8         |   55+  20-
M  gotwebd/gotwebd.conf.5    |  103+   0-
M  gotwebd/gotwebd.h         |    6+   0-
M  gotwebd/pages.tmpl        |   13+   8-
M  gotwebd/parse.y           |  106+   3-
M  include/got_error.h       |    1+   0-
M  lib/error.c               |    1+   0-
M  regress/gotwebd/Makefile  |    1+   0-

11 files changed, 454 insertions(+), 33 deletions(-)

commit - 0982109a05d9bfe118c5979cce77395365ceba4a
commit + c309f46155fedeef7089b4fdd4007e53d88c2979
blob - 9da9ece32167915acfad39d45ed3fec2efd09cbe
blob + a63bc5110b0be05423cf4da5de4837ec9c0ad94e
--- gotwebd/auth.c
+++ gotwebd/auth.c
@@ -17,6 +17,7 @@
  */
 
 #include <sys/queue.h>
+#include <sys/tree.h>
 
 #include <errno.h>
 #include <event.h>
@@ -34,6 +35,7 @@
 #include "got_error.h"
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "gotwebd.h"
 #include "log.h"
@@ -451,6 +453,17 @@ process_request(struct request *c)
 		goto done;
 	}
 
+	/*
+	 * Static gotwebd assets (images, CSS, ...) are not protected
+	 * by authentication.
+	 */
+	if (got_path_cmp(srv->gotweb_url_path, c->fcgi_params.document_uri,
+	    strlen(srv->gotweb_url_path),
+	    strlen(c->fcgi_params.document_uri)) != 0) {
+		forward_request(c);
+		return;
+	}
+
 	auth_config = srv->auth_config;
 	if (c->fcgi_params.qs.path[0] != '\0') {
 		repo = gotweb_get_repository(srv, c->fcgi_params.qs.path);
blob - e1514e1edc7175e78aa0a0cf6ec183f55c300360
blob + df4f8a49c87aba1c7a3e19f855d4000f47a90003
--- gotwebd/config.c
+++ gotwebd/config.c
@@ -51,6 +51,8 @@ config_init(struct gotwebd *env)
 	int i;
 
 	strlcpy(env->httpd_chroot, D_HTTPD_CHROOT, sizeof(env->httpd_chroot));
+	strlcpy(env->htdocs_path, D_HTDOCS_PATH, sizeof(env->htdocs_path));
+	strlcpy(env->gotweb_url_path, "/", sizeof(env->gotweb_url_path));
 
 	env->prefork = GOTWEBD_NUMPROC;
 	TAILQ_INIT(&env->servers);
blob - 56f89783fe3d444b674bbc92188b4ce4a7f237fc
blob + 755ad3573eb6c6a0227db5614b45d844b9382190
--- gotwebd/gotweb.c
+++ gotwebd/gotweb.c
@@ -259,11 +259,117 @@ gotweb_log_request(struct request *c)
 	free(document_uri);
 }
 
+const struct got_error *
+gotweb_serve_htdocs(struct request *c)
+{
+	const struct got_error *error = NULL;
+	struct server *srv = c->srv;;
+	struct gotwebd_fcgi_params *p = &c->fcgi_params;
+	char *document_uri = p->document_uri;
+	char *request_path = NULL;
+	char *child_path = NULL;
+	char *ondisk_path = NULL;
+	int fd = -1;
+	const char *mime_type;
+	char *ext;
+
+	while (document_uri[0] == '/')
+		document_uri++;
+	if (asprintf(&request_path, "/%s", document_uri) == -1)
+		return got_error_from_errno("asprintf");
+
+	if (!got_path_is_child(request_path,
+	    srv->gotweb_url_path, strlen(srv->gotweb_url_path))) {
+		error = got_error(GOT_ERR_NOT_FOUND);
+		goto done;
+	}
+
+	error = got_path_skip_common_ancestor(&child_path,
+	    srv->gotweb_url_path, request_path);
+	if (error) {
+		if (error->code == GOT_ERR_BAD_PATH)
+			error = got_error(GOT_ERR_NOT_FOUND);
+		goto done;
+	}
+
+	if (asprintf(&ondisk_path, "%s/%s/%s", gotwebd_env->httpd_chroot,
+	    srv->htdocs_path, child_path) == -1) {
+		error = got_error_from_errno("asprintf");
+		goto done;
+	}
+
+	fd = open(ondisk_path, O_RDONLY);
+	if (fd == -1) {
+		if (errno == ENOENT || errno == ENOTDIR)
+			error = got_error(GOT_ERR_NOT_FOUND);
+		else
+			error = got_error_from_errno_fmt("open: %s");
+		goto done;
+	}
+
+	/*
+	 * TODO: Port generic mime-type handling from httpd.
+	 *
+	 * This hack works only because we know what files our static
+	 * assets contain. But we should account for other files which
+	 * might be dropped into our htdocs directory.
+	 */
+	ext = strrchr(ondisk_path, '.');
+	if (ext) {
+		if (strcmp(ext, ".css") == 0)
+			mime_type = "text/css";
+		if (strcmp(ext, ".png") == 0)
+			mime_type = "image/png";
+		if (strcmp(ext, ".svg") == 0)
+			mime_type = "image/svg+xml";
+		if (strcmp(ext, ".txt") == 0)
+			mime_type = "text/plain";
+		if (strcmp(ext, ".xml") == 0)
+			mime_type = "text/xml";
+	}
+
+	if (gotweb_reply(c, 200, mime_type, NULL) == -1) {
+		error = got_error(GOT_ERR_IO);
+		goto done;
+	}
+
+	if (template_flush(c->tp) == -1) {
+		error = got_error(GOT_ERR_IO);
+		goto done;
+	}
+
+	for (;;) {
+		uint8_t buf[BUF];
+		ssize_t r;
+
+		r = read(fd, buf, sizeof(buf));
+		if (r == -1) {
+			error = got_error_from_errno("read");
+			goto done;
+		}
+		if (r == 0)
+			break;
+	
+		if (fcgi_write(c, buf, r) == -1) {
+			error = got_error(GOT_ERR_IO);
+			goto done;
+		}
+	}
+done:
+	if (fd != -1 && close(fd) == -1 && error == NULL)
+		error = got_error_from_errno("close");
+	free(request_path);
+	free(child_path);
+	free(ondisk_path);
+	return error;
+}
+
 int
 gotweb_process_request(struct request *c)
 {
 	const struct got_error *error = NULL;
 	struct server *srv = c->srv;;
+	struct gotwebd_fcgi_params *p = &c->fcgi_params;
 	struct querystring *qs = NULL;
 	struct repo_dir *repo_dir = NULL;
 	struct repo_commit *commit;
@@ -278,6 +384,15 @@ gotweb_process_request(struct request *c)
 
 	gotweb_log_request(c);
 
+	if (got_path_cmp(srv->gotweb_url_path, p->document_uri,
+	    strlen(srv->gotweb_url_path), strlen(p->document_uri)) != 0) {
+		error = gotweb_serve_htdocs(c);
+		if (error)
+			goto err;
+
+		return 0;
+	}
+
 	/*
 	 * certain actions require a commit id in the querystring. this stops
 	 * bad actors from exploiting this by manually manipulating the
@@ -509,8 +624,14 @@ gotweb_process_request(struct request *c)
 
 err:
 	c->t->error = error;
-	if (gotweb_reply(c, 400, "text/html", NULL) == -1)
-		return -1;
+	if (error->code == GOT_ERR_NOT_FOUND) {
+		if (gotweb_reply(c, 404, "text/html", NULL) == -1)
+			return -1;
+	} else {
+		if (gotweb_reply(c, 400, "text/html", NULL) == -1)
+			return -1;
+	}
+
 	return gotweb_render_page(c->tp, gotweb_render_error);
 }
 
@@ -1345,6 +1466,30 @@ gotweb_sighdlr(int sig, short event, void *arg)
 }
 
 static void
+unveil_htdocs_path(const char *htdocs_path)
+{
+	struct gotwebd *env = gotwebd_env;
+	char path[PATH_MAX];
+	int ret;
+
+	while (htdocs_path[0] == '/')
+		htdocs_path++;
+
+	ret = snprintf(path, sizeof(path), "%s/%s",
+	    env->httpd_chroot, htdocs_path);
+	if (ret == -1)
+		fatal("snprintf");
+	if ((size_t)ret >= sizeof(path)) {
+		fatalx("htdocs path too long, exceeds %zd bytes: %s",
+		    sizeof(path) - strlen(env->httpd_chroot) - 1,
+		    htdocs_path);
+	}
+
+	if (unveil(path, "r") == -1)
+		fatal("unveil %s", path);
+}
+
+static void
 gotweb_launch(struct gotwebd *env)
 {
 	struct server *srv;
@@ -1358,9 +1503,15 @@ gotweb_launch(struct gotwebd *env)
 		fatal("pledge");
 #endif
 
+	unveil_htdocs_path(env->htdocs_path);
+
 	TAILQ_FOREACH(srv, &gotwebd_env->servers, entry) {
 		if (unveil(srv->repos_path, "r") == -1)
 			fatal("unveil %s", srv->repos_path);
+
+		if (got_path_cmp(env->htdocs_path, srv->htdocs_path, 
+		    strlen(env->htdocs_path), strlen(srv->htdocs_path)) != 0)
+			unveil_htdocs_path(srv->htdocs_path);
 	}
 
 	error = got_privsep_unveil_exec_helpers();
blob - f6353254aaee1f21a9dd7e56f1aff9a4d95f47af
blob + d291c44604303b2f66062abb734ffa8ae610ec60
--- gotwebd/gotwebd.8
+++ gotwebd/gotwebd.8
@@ -139,46 +139,81 @@ Directory for temporary files created by
 .El
 .Sh EXAMPLES
 Example configuration for
-.Xr httpd.conf 5 :
+.Xr httpd.conf 5
+which forwards all requests to
+.Nm :
 .Bd -literal -offset indent
 types { include "/usr/share/misc/mime.types" }
 
 server "example.com" {
 	listen on * port 80
-	root "/htdocs/gotwebd"
-	location "/" {
-		fastcgi socket "/run/gotweb.sock"
-	}
+	fastcgi socket "/run/gotweb.sock"
 }
 .Ed
 .Pp
-Hosting multiple
+The following
+.Xr httpd.conf 5
+example makes multiple
 .Nm gotwebd
-instances on the same HTTP server under different path prefixes, with
-the first reached via the default
-.Ux Ns -domain socket, the second configured to listen on localhost
-port 9000:
+instances reachable via the same HTTP server under different URL path prefixes,
+with the first reached via the default
+.Ux Ns -domain socket ,
+and the second configured to listen on localhost port 9000:
 .Bd -literal -offset indent
 server "example.com" {
 	listen on * port 80
 
-	location "/gotwebd-unix/" {
-		fastcgi socket "/run/gotweb.sock"
-	}
 	location "/gotwebd-unix/*" {
-		root "/htdocs/gotwebd"
-		request strip 1
+		fastcgi socket "/run/gotweb.sock"
 	}
 
-	location "/gotwebd-tcp/" {
-		fastcgi socket tcp localhost 9000
-	}
 	location "/gotwebd-tcp/*" {
-		root "/htdocs/gotwebd"
-		request strip 1
+		fastcgi socket tcp localhost 9000
 	}
+
+	# Redirect requests which lack a trailing slash:
+
+	location "/gotwebd-unix" {
+		block return 302 \\
+			"$REQUEST_SCHEME://$HTTP_HOST$REQUEST_URI/"
+	}
+
+	location "/gotwebd-tcp" {
+		block return 302 \\
+			"$REQUEST_SCHEME://$HTTP_HOST$REQUEST_URI/"
+	}
 }
 .Ed
+.Pp
+When requests do not arrive at the root URL path
+.Dq / ,
+the URL path prefix also needs to be set in the
+.Xr gotwebd.conf 5
+file corresponding to each instance of
+.Nm .
+Following on from the previous example:
+.Pp
+.Xr gotwebd.conf 5
+for the
+.Ux Ns -domain socket
+instance of
+.Nm :
+.Bd -literal -offset indent
+listen on socket "/var/www/run/gotweb.sock"
+server "example.com" {
+	gotweb url_path "/gotwebd-unix"
+}
+.Ed
+.Pp
+.Xr gotwebd.conf 5
+for the TCP instance of
+.Nm :
+.Bd -literal -offset indent
+listen on localhost port 9000
+server "example.com" {
+	gotweb url_path "/gotwebd-tcp"
+}
+.Ed
 .Sh SEE ALSO
 .Xr got 1 ,
 .Xr git-repository 5 ,
blob - 1e09ac9d29942a6f6ec798c0b590593d6f06c339
blob + 742fa3a45d8594ac5066a726a92d3f385cf01717
--- gotwebd/gotwebd.conf.5
+++ gotwebd/gotwebd.conf.5
@@ -60,6 +60,57 @@ Setting the
 to
 .Pa /
 effectively disables chroot.
+.It Ic htdocs Ar path
+Set the path to the directory which contains static files linked from
+HTML generated by
+.Xr gotwebd 8 ,
+such as
+.Pa gotweb.css .
+The specified
+.Ar path
+will be looked up relative to the
+.Ic chroot
+directory of
+.Xr httpd 8 .
+If not specified then
+.Pa htdocs/gotwebd
+will be used.
+.Pp
+Whether these files will be served by
+.Xr gotwebd 8
+or by
+.Xr httpd 8
+depends on how request routing is configured in
+.Xr httpd 8 .
+.Pp
+This setting can also be configured on a per-server basis.
+.It Ic gotweb Ic url_path Ar path
+Sets the URL path under which
+.Xr httpd 8
+is routing requests to
+.Xr gotwebd 8 .
+Defaults to
+.Dq / .
+.Pp
+The URL
+.Ar path
+should match the path of the
+.Ic location
+block in
+.Xr httpd.conf 5
+which forwards requests to
+.Xr gotwebd 8
+via the FastCGI socket.
+If the URL path is not configured correctly then
+.Xr gotwebd 8
+will not be able to serve static files from the
+.Ic htdocs
+directory.
+Browsers will not be able to load CSS and image files unless these files
+are served directly from
+.Xr httpd 8 .
+.Pp
+This setting can also be configured on a per-server basis.
 .It Ic disable authentication
 Disable authentication, allowing any browser to view any repository
 not hidden via the
@@ -211,6 +262,58 @@ Defaults to
 .Pp
 This path must be valid in the web server's URL space since browsers
 will attempt to fetch it.
+.It Ic htdocs Ar path
+Set the path to the directory which contains static files linked from
+HTML generated by
+.Xr gotwebd 8 ,
+such as
+.Pa gotweb.css .
+The specified
+.Ar path
+will be looked up relative to the
+.Ic chroot
+directory of
+.Xr httpd 8 .
+If not specified then the global
+.Ic htdocs
+configuration setting applies.
+.Pp
+Whether these files will be served by
+.Xr gotwebd 8
+or by
+.Xr httpd 8
+depends on how request routing is configured in
+.Xr httpd 8 .
+.It Ic gotweb Ic url_path Ar path
+Sets the URL path under which
+.Xr httpd 8
+is routing requests to this
+.Xr gotwebd 8
+server.
+Defaults to
+.Dq / .
+.Pp
+The URL
+.Ar path
+should match the path of the
+.Ic location
+block in
+.Xr httpd.conf 5
+which forwards requests to
+.Xr gotwebd 8
+via the FastCGI socket.
+If the URL path is not configured correctly then
+.Xr gotwebd 8
+will not be able to serve static files from the
+.Ic htdocs
+directory.
+Browsers will not be able to load CSS and image files unless these files
+are served directly from
+.Xr httpd 8 .
+.Pp
+If not set then the global
+.Ic gotweb url_path
+setting will be used.
 .It Ic disable authentication
 Disable authentication for this server, allowing any browser to view any
 repository not hidden via the
blob - 128b7e7cf5551cea25d7f7a8e8e5f63470c1ad79
blob + a8b4f2e7754cad07edc2f4cf48bd18cbe838db74
--- gotwebd/gotwebd.h
+++ gotwebd/gotwebd.h
@@ -65,6 +65,7 @@
 #define GOTWEB_GIT_DIR		 ".git"
 
 #define D_HTTPD_CHROOT		 "/var/www"
+#define D_HTDOCS_PATH		 "/htdocs/gotwebd"
 #define D_UNIX_SOCKET		 "/run/gotweb.sock"
 #define D_FCGI_PORT		 "9000"
 #define D_GOTPATH		 "/got/public"
@@ -394,6 +395,8 @@ struct server {
 	TAILQ_ENTRY(server)	 entry;
 
 	char		 name[GOTWEBD_MAXTEXT];
+	char		 htdocs_path[PATH_MAX];
+	char		 gotweb_url_path[MAX_DOCUMENT_URI];
 
 	char		 repos_path[PATH_MAX];
 	char		 site_name[GOTWEBD_MAXNAME];
@@ -489,6 +492,8 @@ struct gotwebd {
 	int		 *worker_load;
 
 	char		 httpd_chroot[PATH_MAX];
+	char		 htdocs_path[PATH_MAX];
+	char		 gotweb_url_path[MAX_DOCUMENT_URI];
 	uid_t		 www_uid;
 
 	char		 login_hint_user[MAX_IDENTIFIER_SIZE];
@@ -583,6 +588,7 @@ int gotweb_render_absolute_url(struct request *, struc
 void gotweb_free_repo_commit(struct repo_commit *);
 void gotweb_free_repo_tag(struct repo_tag *);
 void gotweb_log_request(struct request *);
+const struct got_error *gotweb_serve_htdocs(struct request *);
 int gotweb_process_request(struct request *);
 void gotweb_free_transport(struct transport *);
 void gotweb(struct gotwebd *, int);
blob - c525c3ba19fb057b82ee8fc2ec2d1d80f5f03191
blob + 81693c5e934d8493c3788f5d49b453b59fbfc3fc
--- gotwebd/pages.tmpl
+++ gotwebd/pages.tmpl
@@ -18,6 +18,7 @@
 
 #include <sys/types.h>
 #include <sys/queue.h>
+#include <sys/tree.h>
 #include <sys/stat.h>
 
 #include <ctype.h>
@@ -33,6 +34,7 @@
 #include "got_error.h"
 #include "got_object.h"
 #include "got_reference.h"
+#include "got_path.h"
 
 #include "gotwebd.h"
 #include "log.h"
@@ -167,9 +169,12 @@ nextsep(char *s, char **t)
 	struct server		*srv = c->srv;
 	const struct querystring *qs = c->t->qs;
 	struct gotweb_url	 u_path;
-	const char		*prfx = c->fcgi_params.document_uri;
+	char			 prefix[MAX_DOCUMENT_URI];
 	const char		*css = srv->custom_css;
 
+	strlcpy(prefix, srv->gotweb_url_path, sizeof(prefix));
+	got_path_strip_trailing_slashes(prefix);
+
 	memset(&u_path, 0, sizeof(u_path));
 	u_path.index_page = -1;
 	u_path.action = SUMMARY;
@@ -182,18 +187,18 @@ nextsep(char *s, char **t)
     <meta name="viewport" content="initial-scale=1.0" />
     <meta name="msapplication-TileColor" content="#da532c" />
     <meta name="theme-color" content="#ffffff"/>
-    <link rel="apple-touch-icon" sizes="180x180" href="{{ prfx }}apple-touch-icon.png" />
-    <link rel="icon" type="image/png" sizes="32x32" href="{{ prfx }}favicon-32x32.png" />
-    <link rel="icon" type="image/png" sizes="16x16" href="{{ prfx }}favicon-16x16.png" />
-    <link rel="manifest" href="{{ prfx }}site.webmanifest"/>
-    <link rel="mask-icon" href="{{ prfx }}safari-pinned-tab.svg" />
-    <link rel="stylesheet" type="text/css" href="{{ prfx }}{{ css }}" />
+    <link rel="apple-touch-icon" sizes="180x180" href="{{ prefix }}/apple-touch-icon.png" />
+    <link rel="icon" type="image/png" sizes="32x32" href="{{ prefix }}/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="{{ prefix }}/favicon-16x16.png" />
+    <link rel="manifest" href="{{ prefix }}/site.webmanifest"/>
+    <link rel="mask-icon" href="{{ prefix }}/safari-pinned-tab.svg" />
+    <link rel="stylesheet" type="text/css" href="{{ prefix }}/{{ css }}" />
   </head>
   <body>
     <header id="header">
       <div id="got_link">
         <a href="{{ srv->logo_url }}" target="_blank">
-          <img src="{{ prfx }}{{ srv->logo }}" />
+          <img src="{{ prefix }}/{{ srv->logo }}" />
         </a>
       </div>
     </header>
blob - 7c9043e687e6963d202be1207ce3e0ababcd7c14
blob + 7ea01f7e2aefdeeb4f7044f82b4a3e8f26526f58
--- gotwebd/parse.y
+++ gotwebd/parse.y
@@ -124,7 +124,7 @@ typedef struct {
 %token	LOGO_URL SHOW_REPO_OWNER SHOW_REPO_AGE SHOW_REPO_DESCRIPTION
 %token	MAX_REPOS_DISPLAY REPOS_PATH MAX_COMMITS_DISPLAY ON ERROR
 %token	SHOW_SITE_OWNER SHOW_REPO_CLONEURL PORT PREFORK RESPECT_EXPORTOK
-%token	SERVER CHROOT CUSTOM_CSS SOCKET HINT
+%token	SERVER CHROOT CUSTOM_CSS SOCKET HINT HTDOCS GOTWEB URL_PATH
 %token	SUMMARY_COMMITS_DISPLAY SUMMARY_TAGS_DISPLAY USER AUTHENTICATION
 %token	ENABLE DISABLE INSECURE REPOSITORY REPOSITORIES PERMIT DENY HIDE
 
@@ -337,6 +337,50 @@ main		: PREFORK NUMBER {
 			}
 			free($4);
 		}
+		| HTDOCS STRING {
+			if (*$2 == '\0') {
+				yyerror("htdocs path can't be an empty"
+				    " string");
+				free($2);
+				YYERROR;
+			}
+
+			n = strlcpy(gotwebd->htdocs_path, $2,
+			    sizeof(gotwebd->htdocs_path));
+			if (n >= sizeof(gotwebd->htdocs_path)) {
+				yyerror("htdocs path too long: %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
+		| GOTWEB URL_PATH STRING {
+			if (*$3 == '\0') {
+				yyerror("gotweb url_path can't be an empty"
+				    " string");
+				free($3);
+				YYERROR;
+			}
+
+			n = strlcpy(gotwebd->gotweb_url_path, $3,
+			    sizeof(gotwebd->gotweb_url_path));
+			if (n >= sizeof(gotwebd->gotweb_url_path)) {
+				yyerror("gotweb url_path too long, exceeds "
+				    "%zd bytes: %s",
+				    sizeof(gotwebd->gotweb_url_path), $3);
+				free($3);
+				YYERROR;
+			}
+
+			if (gotwebd->gotweb_url_path[0] != '/') {
+				yyerror("gotweb url path must be an absolute "
+				    "path: bad path %s", $3);
+				free($3);
+				YYERROR;
+			}
+
+			free($3);
+		}
 		;
 
 server		: SERVER STRING {
@@ -541,6 +585,48 @@ serveropts1	: REPOS_PATH STRING {
 			conf_new_access_rule(&new_srv->access_rules,
 			    GOTWEBD_ACCESS_DENIED, $2);
 		}
+		| HTDOCS STRING {
+			if (*$2 == '\0') {
+				yyerror("htdocs path can't be an empty"
+				    " string");
+				free($2);
+				YYERROR;
+			}
+
+			n = strlcpy(new_srv->htdocs_path, $2,
+			    sizeof(new_srv->htdocs_path));
+			if (n >= sizeof(new_srv->htdocs_path)) {
+				yyerror("htdocs path too long: %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
+		| GOTWEB URL_PATH STRING {
+			if (*$3 == '\0') {
+				yyerror("gotweb url_path can't be an empty"
+				    " string");
+				free($3);
+				YYERROR;
+			}
+
+			n = strlcpy(new_srv->gotweb_url_path, $3,
+			    sizeof(new_srv->gotweb_url_path));
+			if (n >= sizeof(new_srv->gotweb_url_path)) {
+				yyerror("htdocs path too long: %s", $3);
+				free($3);
+				YYERROR;
+			}
+
+			if (gotwebd->gotweb_url_path[0] != '/') {
+				yyerror("gotweb url path must be an absolute "
+				    "path: bad path %s", $3);
+				free($3);
+				YYERROR;
+			}
+
+			free($3);
+		}
 		| repository
 		;
 
@@ -658,8 +744,10 @@ lookup(char *s)
 		{ "deny",			DENY },
 		{ "disable",			DISABLE },
 		{ "enable",			ENABLE },
+		{ "gotweb",			GOTWEB },
 		{ "hide",			HIDE },
 		{ "hint",			HINT },
+		{ "htdocs",			HTDOCS },
 		{ "insecure",			INSECURE },
 		{ "listen",			LISTEN },
 		{ "login",			GOTWEBD_LOGIN },
@@ -687,6 +775,7 @@ lookup(char *s)
 		{ "socket",			SOCKET },
 		{ "summary_commits_display",	SUMMARY_COMMITS_DISPLAY },
 		{ "summary_tags_display",	SUMMARY_TAGS_DISPLAY },
+		{ "url_path",			URL_PATH },
 		{ "user",			USER },
 		{ "www",			WWW },
 	};
@@ -1110,7 +1199,7 @@ parse_config(const char *filename, struct gotwebd *env
 		break;
 	}
 
-	/* Inherit implicit authentication/hidden config from parent scope. */
+	/* Inherit implicit configuration from parent scope. */
 	TAILQ_FOREACH(srv, &env->servers, entry) {
 		if (srv->auth_config == 0)
 			srv->auth_config = env->auth_config;
@@ -1130,7 +1219,17 @@ parse_config(const char *filename, struct gotwebd *env
 				    sizeof(srv->login_hint_user) - 1);
 			}
 		}
-	
+
+		if (srv->gotweb_url_path[0] == '\0') {
+			if (strlcpy(srv->gotweb_url_path,
+			    env->gotweb_url_path,
+			    sizeof(srv->gotweb_url_path)) >=
+			    sizeof(srv->gotweb_url_path)) {
+				yyerror("gotweb url_path too long, "
+				    "exceeds %zd bytes",
+				    sizeof(srv->gotweb_url_path) - 1);
+			}
+		}
 	}
 
 	return (0);
@@ -1156,6 +1255,10 @@ conf_new_server(const char *name)
 	    sizeof(srv->repos_path));
 	if (n >= sizeof(srv->repos_path))
 		fatalx("%s: strlcat", __func__);
+	n = strlcpy(srv->htdocs_path, D_HTDOCS_PATH,
+	    sizeof(srv->htdocs_path));
+	if (n >= sizeof(srv->htdocs_path))
+		fatalx("%s: strlcpy", __func__);
 	n = strlcpy(srv->site_name, D_SITENAME,
 	    sizeof(srv->site_name));
 	if (n >= sizeof(srv->site_name))
blob - 6a8c2beac69184d483845a613349d8c35c5d7bff
blob + a95325914b29d50c3c459fdeb4fb86e2d076ac6d
--- include/got_error.h
+++ include/got_error.h
@@ -199,6 +199,7 @@
 #define GOT_ERR_ON_SERVER_SIDE	191
 #define GOT_ERR_LOGIN_FAILED	192
 #define GOT_ERR_UNKNOWN_COMMAND	193
+#define GOT_ERR_NOT_FOUND	194
 
 struct got_error {
         int code;
blob - 641aa3dc732ea1dcca0e663e75295368e54b0684
blob + 577dd1bf907e50ca8e8df206486ea926317f32ab
--- lib/error.c
+++ lib/error.c
@@ -250,6 +250,7 @@ static const struct got_error got_errors[] = {
 	{ GOT_ERR_ON_SERVER_SIDE, "see server-side logs for error details" },
 	{ GOT_ERR_LOGIN_FAILED, "login failed" },
 	{ GOT_ERR_UNKNOWN_COMMAND, "command not found" },
+	{ GOT_ERR_NOT_FOUND, "not found" },
 };
 
 static struct got_custom_error {
blob - 2a9550ced8b92e7894ec5118315bcc491fac2dd8
blob + fc3a1211e2600ccbdf77066238be0fba27dce020
--- regress/gotwebd/Makefile
+++ regress/gotwebd/Makefile
@@ -76,6 +76,7 @@ gotwebd_test_conf:
 	@echo '    show_repo_owner off' >> ${GOTWEBD_TEST_CONF}
 	@echo '}' >> ${GOTWEBD_TEST_CONF}
 	@echo 'disable authentication' >> ${GOTWEBD_TEST_CONF}
+	@echo 'gotweb url_path "/gotwebd_test_harness"' >> $(GOTWEBD_TEST_CONF)
 
 gotwebd_test_conf_paginate: gotwebd_test_conf
 	@printf '5i\n    max_commits_display 3\n.\nwq\n' | \