Repository: mbrubeck/agate (language: rust)

Issue:
Support multiple --hostname's
Greetings, please add support for multiple occurrences of --hostname

If would be nice to be able to limit responses only to ```domain.com```, ```gemini.domain.com``` and ```gmi.domain.com``` for example.

What do you think?


Discussion / hints:
🤔 Not sure what you mean. Do you mean these domains should access the same or different content?

If they should access the same content, you can just not use `--hostname`, which will assume the requested domain is the one this server is serving. Although in that case I would wonder why you want to have multiple domains for the same thing.
Personally, I would like the ability to serve different files for different hostnames.

That way, if I have multiple domains pointing at the same server, I can have the same server serving different Gemini capsules.

This would probably mean either having the possibility to specify a content folder for each hostname or serving the content from a subfolder inside `--content` with the name of the hostname.

Example:
`gemini://a.com` => `/content/a.com/index.gmi`
`gemini://a.com/hello.gmi` => `/content/a.com/hello.gmi`
`gemini://b.com` => `/content/b.com/index.gmi`
@dancojocaru2000 What you are looking for is called VHosts, and is not something Agate should implement seeing it is a "very simple Gemini server". I would recommend that you take a look at other software, e.g. [`gemserv`](https://git.sr.ht/~int80h/gemserv) which already has support for more advanced features like this.
To add a bit more reasoning to this:
I think this would be quite complicated because you would also have to allow to serve different certificates for the different domains (using TLS SNI), although there is the possibility to use one certificate for multiple domains. But I am not sure that is the nice way to do it. And anyway if you really want to do this with Agate, you already can by not setting the hostname, pointing the DNS for different domains to the same server and having a certificate for all the domains. Only the part with mapping to different directories is not possible at the moment.
Of course if you think this is easier than I think, please change my mind (or even submit a PR).
Maybe something like this?
***
### Virtual Hosts

Agate has support for a very simple version of virtual hosts. If you specify multiple `--hostname`s, Agate will look in the content root directory in a directory with the respective hostname.
For example if one hostname is `example.com`, the content directory is set to `./content` and `gemini://example.com/` is requested, Agate will look for files in `./content/example.com/`. This behaviour is only enabled if multiple `--hostname`s are specified.
Agate does not have support for multiple certificates, you will have to use a multi domain certificate.
Something like that would be awesome!

Fix patch:
diff --git a/README.md b/README.md
index 06dd0da..394cf27 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,14 @@ gone.gmi:52 This file is no longer here, sorry.
 
 Agate uses the `env_logger` crate and allows you to set the logging verbosity by setting the default `RUST_LOG` environment variable. For more information, please see the [documentation of `env_logger`].
 
+### Virtual Hosts
+
+Agate has basic support for virtual hosts. If you specify multiple `--hostname`s, Agate will look in a directory with the respective hostname within the content root directory.
+For example if one of the hostnames is `example.com`, and the content root directory is set to the default `./content`, and `gemini://example.com/file.gmi` is requested, then Agate will look for `./content/example.com/file.gmi`. This behaviour is only enabled if multiple `--hostname`s are specified.
+Agate does not support different certificates for different hostnames, you will have to use a single certificate for all domains (multi domain certificate).
+
+If you want to serve the same content for multiple domains, you can instead disable the hostname check by not specifying `--hostname`. In this case Agate will disregard a request's hostname apart from checking that there is one.
+
 [Gemini]: https://gemini.circumlunar.space/
 [Rust]: https://www.rust-lang.org/
 [home]: gemini://gem.limpet.net/agate/
diff --git a/src/main.rs b/src/main.rs
index 545839f..c947036 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -69,7 +69,7 @@ struct Args {
     content_dir: String,
     cert_file: String,
     key_file: String,
-    hostname: Option<Host>,
+    hostnames: Vec<Host>,
     language: Option<String>,
     silent: bool,
     serve_secret: bool,
@@ -101,13 +101,13 @@ fn args() -> Result<Args> {
     opts.optmulti(
         "",
         "addr",
-        "Address to listen on (multiple occurences possible, default 0.0.0.0:1965 and [::]:1965)",
+        "Address to listen on (default 0.0.0.0:1965 and [::]:1965; muliple occurences means listening on multiple interfaces)",
         "IP:PORT",
     );
-    opts.optopt(
+    opts.optmulti(
         "",
         "hostname",
-        "Domain name of this Gemini server (optional)",
+        "Domain name of this Gemini server (default is not checking hostname or port; multiple occurences means basic vhosts)",
         "NAME",
     );
     opts.optopt(
@@ -135,10 +135,10 @@ fn args() -> Result<Args> {
         let usage = opts.usage(&format!("Usage: {} [options]", &args[0]));
         return Err(usage.into());
     }
-    let hostname = match matches.opt_str("hostname") {
-        Some(s) => Some(Host::parse(&s)?),
-        None => None,
-    };
+    let mut hostnames = vec![];
+    for s in matches.opt_strs("hostname") {
+        hostnames.push(Host::parse(&s)?);
+    }
     let mut addrs = vec![];
     for i in matches.opt_strs("addr") {
         addrs.push(i.parse()?);
@@ -154,7 +154,7 @@ fn args() -> Result<Args> {
         content_dir: check_path(matches.opt_get_default("content", "content".into())?)?,
         cert_file: check_path(matches.opt_get_default("cert", "cert.pem".into())?)?,
         key_file: check_path(matches.opt_get_default("key", "key.rsa".into())?)?,
-        hostname,
+        hostnames,
         language: matches.opt_str("lang"),
         silent: matches.opt_present("s"),
         serve_secret: matches.opt_present("serve-secret"),
@@ -278,14 +278,16 @@ impl RequestHandle {
         if url.scheme() != "gemini" {
             return Err((53, "Unsupported URL scheme"));
         }
-        // TODO: Can be simplified by https://github.com/servo/rust-url/pull/651
-        if let (Some(Host::Domain(expected)), Some(Host::Domain(host))) =
-            (url.host(), &ARGS.hostname)
-        {
-            if host != expected {
+
+        if let Some(host) = url.host() {
+            // TODO: to_owned can be removed in next version of url https://github.com/servo/rust-url/pull/651
+            if !ARGS.hostnames.is_empty() && !ARGS.hostnames.contains(&host.to_owned()) {
                 return Err((53, "Proxy request refused"));
             }
+        } else {
+            return Err((59, "URL does not contain a host"));
         }
+
         if let Some(port) = url.port() {
             // Validate that the port in the URL is the same as for the stream this request came in on.
             if port != self.stream.get_ref().0.local_addr().unwrap().port() {
@@ -298,6 +300,12 @@ impl RequestHandle {
     /// Send the client the file located at the requested URL.
     async fn send_response(&mut self, url: Url) -> Result {
         let mut path = std::path::PathBuf::from(&ARGS.content_dir);
+
+        if ARGS.hostnames.len() > 1 {
+            // basic vhosts, existence of host_str was checked by parse_request already
+            path.push(url.host_str().expect("no hostname"));
+        }
+
         if let Some(segments) = url.path_segments() {
             for segment in segments {
                 if !ARGS.serve_secret && segment.starts_with('.') {


Test patch: