:.: Nextcloud + OpenBSD = <3

Looks like everything is made of clouds and farts now, so we cannot be out of it we need to keep up on the trend. I maintain the nextcloud port since ever, and I am using since then, to be honest it's pretty cool and handy, despite all the php slowness and other flavors the system has and the OpenBSD's issues as client and server (in comparation with loonix and others), it works really really well in every way I use it; which is as "multimedia cloud", "CalDAV/CardDAV" server for my android phone, "Password Manager" and "Backups" in general.

:. Install

As I explained before in other articles, I like to keep using base system as much as I can, I like minimal stuff, I like minimalism and OpenBSD is very minimal and full of tools for the task. The setup will be with relayd(8) in front, and httpd(8) behind, the only package we need it's nextcloud, the rest will come along with it, so we do:

$ doas pkg_add nextcloud
quirks-6.101 signed on 2023-02-14T11:05:49Z
quirks-6.101: ok
Ambiguous: choose package for nextcloud
a       0: <None>
        1: nextcloud-23.0.12p0
        2: nextcloud-24.0.9p0
        3: nextcloud-25.0.3p0
Your choice: 3
---
PORTS MAGIC
---
nextcloud-25.0.3p0: ok
Running tags: ok
The following new rcscripts were installed: /etc/rc.d/php81_fpm
See rcctl(8) for details.
New and changed readme(s):
        /usr/local/share/doc/pkg-readmes/femail-chroot
        /usr/local/share/doc/pkg-readmes/glib2
        /usr/local/share/doc/pkg-readmes/nextcloud
        /usr/local/share/doc/pkg-readmes/php-8.1

I recommend if you are using -current to use the higher one, it will be the one keeping get upgrades and changing to the new major one in the port and probably the one getting more updates from upstream. As always I will not explain you in details what it's already in the README, which btw, you should know that you can find as the others here: /usr/local/share/doc/pkg-readmes/. If you read the file, you will see that you have an example file for httpd(8), and I will show you my version of it, which not differ much from the one in the README file:

$ doas cat /etc/httpd.conf
server "cloud.x61.sh" {
        listen on * tls port 443
        listen on * port 80

        location "/.well-known/acme-challenge/*" {
                root "/acme"
                request strip 2
        }

        root "/nextcloud"

        hsts max-age 15768000

        tls {
                certificate "/etc/ssl/cloud.x61.sh.crt"
                key "/etc/ssl/private/cloud.x61.sh.key"
        }

        # Set max upload size
        connection max request body 537919488
        connection max requests 1000
        connection request timeout 3600
        connection timeout 3600
        tcp nodelay

        gzip-static

        # First deny access to the specified files
        location "/db_structure.xml"            { block }
        location "/README"                      { block }
        location "/config*"                     { block }
        location "/build*"                      { block }
        location "/tests*"                      { block }
        location "/lib*"                        { block }
        location "/3rdparty*"                   { block }
        location "/templates*"                  { block }
        location "/data*"                       { block }
        location "/.ht*"                        { block }
        location "/.user*"                      { block }
        location "/autotest*"                   { block }
        location "/occ*"                        { block }
        location "/issue*"                      { block }
        location "/indie*"                      { block }
        location "/db_*"                        { block }
        location "/console*"                    { block }

        location "/core/*" {
                gzip-static
                pass                                                                                      
        }

        location "/apps/*" {
                gzip-static
                pass
        }

        location "/dist/*" {
                gzip-static
                pass
        }

        location "/.well-known/carddav" {
                block return 301 "/remote.php/dav/"
        }

        location "/.well-known/caldav" {
                block return 301 "/remote.php/dav/"
        }

        location match "/oc[ms]%-provider/*" {
                directory index index.php
                pass
        }

        location "/.well-known/webfinger" {
                block return 301 "/index.php$REQUEST_URI"
        }

        location "/.well-known/nodeinfo" {
                block return 301 "/index.php$REQUEST_URI"
        }
 
        location "/.well-known/host-meta" {
                block return 301 "/public.php?service=host-meta"
        }

        location "/.well-known/host-meta.json" {
                block return 301 "/public.php?service=host-meta-json"
        }

        location "/*.php*" {
                fastcgi socket "/run/php-fpm.sock"
        }
}

:. Nextcloud config.php

For the DB I use postgresql and redis as in-memory data store, I have no special setup for those more than the one on their READMEs, so I will move to the "/var/www/nextcloud/config/config.php" file of my nextcloud setup, I will breakdown the important parts that are not there by default:

...
  'trusted_domains' => 
  array (
    0 => '10.10.0.9',
    1 => 'cloud.x61.sh',
  ),
  'trusted_proxies' =>
  array (
    0 => '10.10.0.1',
    1 => '10.10.0.9',
    2 => '127.0.0.1',
  ),
...

"trusted_domains" and "trusted_proxies", are just that, those domains, IPs or proxies that nextcloud will mark as safe for your instance, I have an internal IP for my local network and the public domain, as I have the proxy/ies in front of my nextcloud to try different things, you probably don't need this last one.

...
  'overwriteprotocol' => 'https',
  'memcache.local' => '\\OC\\Memcache\\Redis',
  'memcache.locking' => '\\OC\\Memcache\\Redis',
  'redis' => 
  array (
    'host' => '127.0.0.1',
    'port' => 6379,
    'timeout' => 1.5,
  ),
...

"overwriteprotocol" so let's make it always go over "https" specially if you have it facing the interwebz, at home in a local network, well, maybe you can use "http" but why?. The next options are for our cache (nextcloud cache), remember that we installed redis and you followed the README to make it work, so now that you have it listening on the default port in "127.0.0.1" you can add it to your conf.

...
  'filesystem_check_changes' => 1,
...

"filesystem_check_changes" as config_sample_php_parameters explains this option specifies how often the local filesystem (the Nextcloud data/ directory, and NFS mounts in data/) is checked for changes made outside Nextcloud, I have it enable since my "files" in nextcloud reside in a crypto partition on another disk, you might not need it in a different setup but remember that if you "scp" or copy raw files from some drive to the "nextcloud-data" directory inside your user on the instance without uploaded them over the page, nextcloud won't see those files, so for those cases, you should enable "filesystem_check_changes".

...
  'mail_from_address' => 'cloud',
  'mail_smtpmode' => 'smtp',
  'mail_smtpauthtype' => 'PLAIN',
  'mail_domain' => 'cloud.x61.sh',
  'mail_smtphost' => '127.0.0.1',
  'mail_sendmailmode' => 'smtp',
...

These are pretty clear I believe, and yes, get emails with random information it's not cool, but I found those in this particular case important, since enabling the email notification you can get emails of modified files, login attempts, brute force attacks and many other things, I recommend to enable it, but it's not need it.

...
  'app_install_overwrite' => 
  array (
    0 => 'calendar',
    1 => 'bruteforcesettings',
    2 => 'twofactor_yubikey',
    3 => 'twofactor_u2f',
    4 => 'side_menu',
    5 => 'impersonate',
    6 => 'duplicatefinder',
  ),
...

Well this is not something you should have, but those are some of the apps I use, in case you need it, a calendar (which is really useful for your android or ios phone to keep things in sync), bruteforcesettings blocks stupid attemps of login, twofactor_* are apps to use yubikey, solokeys or freeopt and have a 2fa, side_menu because I don't know who was the genious that created that awful default menu on top with all the titles together, impersonate which is useful to impersonate your mum user and re-arrange all the photos she has so then you can use the duplicatefinder to delete those 332 copies of the same photo she uploaded in different places.

...
  'maintenance' => false,
);
...

This is an important one, if your upgrade or installation went wrong for X reason and you cannot access again your instance, you should probably look at this flag, fix the issue and turn it into "false" again, for more options you should check: config_sample_php_parameters.

So far at this point, if you have the services up (db, php and httpd(8)) your nextcloud should be running nicely, but what about those gzip on the httpd.conf(5)? what about all the Content Security Policy (CSP) we need to make it secure? what about let's encrypt certs?

:. Gzip

Around OpenBSD 7.1, httpd(8) got the feature to serves pre-compressed files, so as you saw on the httpd.conf(5) above we have something like this:

...
        location "/core/*" {
                gzip-static
                pass
        }
...

Exactly, there is where nextcloud has a lot of huge .js and .css files, so why not "gzip" them to make them go faaaaaster? After every update I usually run something similar to this:

# find /var/www/nextcloud \( -name "*.js" -o -name "*.css" \) -size +100000c -exec gzip -f -k "{}" \;

If you check on those files now, you will see that are .gz and you will notice some speed up on your instance.

:. Nextcloud updates

To keep nextcloud package up-to-date on OpenBSD we just need to run pkg_add -Vu on -release and pkg_add -Vu -Dsnap on -current, these will upgrade your Nextcloud's files, and then we need to upgrade the db and other things inside of it, you have 2 ways to do this; over the browser opening your domain it will guide you through the process or you can use the occ command. On OpenBSD we have chroot(2) and our httpd(8) is working under it, so to update the instance with the command line we should do something like:

$ doas -u www /usr/local/bin/php-8.1 /var/www/nextcloud/occ upgrade

This will manage the whole update and get the instance ready to use with the new version, if you are gziping *.js and *.css files you should re-run the find line to compress the new ones.

:. Relayd CSP and Certs

Let's put some other layer of security by adding some CSP headers, if you don't know what they are, follow the previous link, you will have plenty of details there. httpd(8) has no way to add headers itself so to give it some help I will put relayd(8) in front of it and play around with headers and the certificate for our nextcloud.

Yes, I specified my certificates in our httpd.conf(5) because after created them over relayd(8) I scp them to the cloud server, just in case, but you don't need it, relayd(8) will manage them for you, anyway it is the right conf for people without using relayd(8) on front.

relayd(8) as the man says: is a daemon to relay and dynamically redirect incoming connections to a target host. Its main purposes are to run as a load-balancer, application layer gateway, or transparent proxy, and that is what we gonna do, I will show my relayd.con(5) and explain a bit parts of it as example:

table  <honk>    { 10.10.0.7 }
table  <matrix>  { 10.10.0.8 }
table  <certs>   { 127.0.0.1 }
table  <cloud>   { 10.10.0.9 }

log state changes
log connection

http protocol "http" {
  block

  match header set "X-Client-IP" value "$REMOTE_ADDR:$REMOTE_PORT"
  match header set "X-Forwarded-For" value "$REMOTE_ADDR"
  match request header set "X-Forwarded-Port" value "$REMOTE_PORT"
  match header set "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT"
  match header set "Keep-Alive" value "$TIMEOUT"
  match query hash "sessid"

  match response header remove "Server"
  match response header set "X-Robots-Tag" value "none"
  match response header set "Permissions-Policy" value "interest-cohort=()"
  match response header set "Cache-Control" value "public, no-cache, must-revalidate, max-age=1814400"
  match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
  match response header set "X-Content-Type-Options" value "nosniff"
  match response header set "X-XSS-Protection" value "1; mode=block"
  match response header set "Referrer-Policy" value "no-referrer"
  match response header set "Permissions-Policy" value "autoplay 'self'; geolocation 'none';payment 'none'"
  match response header set "Content-Security-Policy" value "default-src https:; style-src 'self' 'unsafe-inline';img-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval';object-src 'self'; frame-ancestors 'self';base-uri 'self';connect-src 'self';media-src 'self'; manifest-src 'self';font-src 'self' data:;worker-src 'self' blob:;form-action 'self';"

  match request quick path "/.well-known/acme-challenge/*" tag "certs"
  pass request quick tagged "certs" forward to <certs>

  match request quick header "Host" value "cloud.x61.sh" tag "cloud"
  pass request quick tagged "cloud" forward to <cloud>

  match request quick header "Host" value "honk.x61.sh" tag "honk"
  pass request quick tagged "honk" forward to <honk>

  match request quick header "Host" value "m.x61.sh" tag "matrix"
  match request quick path "/_matrix/*" tag "matrix"
  pass request quick tagged "matrix" forward to <matrix>

  tls keypair "m.x61.sh"
  tls keypair "honk.x61.sh"
  tls keypair "cloud.x61.sh"

  tls { no tlsv1.0, ciphers "HIGH" }
  tls ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"
  tcp { nodelay, socket buffer 65536, backlog 100 }
}

relay "https" {
  listen on egress port 443 tls
  listen on egress port 80

  protocol "http"

  forward to <honk> port 31337 check tcp
  forward to <matrix> port 8448 check tcp
  forward to <certs> port 3080 check tcp
  forward to <cloud> port 80 check tcp
}

On the top of the file you will see my tables with their IPs for each services behind my relayd, if you read my other articles you will remember those. We "log" connections and state changes, then the fun part starts where we define a "http protocol" called "http" (you can set any name here), to manage all our http connections and add what we need for our setup, in this case headers.

...
match header set "X-Client-IP" value "$REMOTE_ADDR:$REMOTE_PORT"
match header set "X-Forwarded-For" value "$REMOTE_ADDR"
match request header set "X-Forwarded-Port" value "$REMOTE_PORT"
match header set "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT"
match header set "Keep-Alive" value "$TIMEOUT"
match query hash "sessid"
...

Here we tell to relayd (our reverse proxy) to use X-Forwarded-For and X-Forwarded-By in the connections it passes to httpd (honk, certs and cloud) without those you will not see any entries in your access log (and for this you will need "log style forwarded" in your httpd.conf from above), we also set the "Keep-Alive" header and "sessid".

...
match response header remove "Server"
match response header set "X-Robots-Tag" value "none"
match response header set "Permissions-Policy" value "interest-cohort=()"
match response header set "Cache-Control" value "public, no-cache, must-revalidate, max-age=1814400"
match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
match response header set "X-Content-Type-Options" value "nosniff"
match response header set "X-XSS-Protection" value "1; mode=block"
match response header set "Referrer-Policy" value "no-referrer"
match response header set "Permissions-Policy" value "autoplay 'self'; geolocation 'none';payment 'none'"
match response header set "Content-Security-Policy" value "default-src https:; style-src 'self' 'unsafe-inline';img-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval';object-src 'self'; frame-ancestors 'self';base-uri 'self';connect-src 'self';media-src 'self'; manifest-src 'self';font-src 'self' data:;worker-src 'self' blob:;form-action 'self';"
...

Big long block now, all the headers that make CSP happy. The syntax it's very simple, take a look at the manual, but basically we "match response" from the manual "the request, a client initiating a new connection to a server via the relay, and the response, the server accepting the connection." then we "set header" to our needs depending of what we want, in this case a bunch of them are for nextcloud in particular but the rest are for example to provide a layer to mitigate XSS attacks by restricting which scripts can be executed by the page. There are tons of different ones, and depends on what are you trying to do or which kind of software your website is using you will need to adjust accordingly to it, another good site to look at all the variations is this one.

...
match request quick path "/.well-known/acme-challenge/*" tag "certs"
pass request quick tagged "certs" forward to <certs>
...

I will explain this chunk first but the rest following are kinda the same. To get acme-client to find the "/.well-known/acme-challenge/" for all our sites behind, we need to tell relayd what to do when a http request ask for "/.well-known/acme-challenge/*". For this I have in my 127.0.0.1 machine an httpd running just to serve the certificate directories, the httpd.conf looks like this:

server "foobar.x61.sh" {
  listen on 127.0.0.1 port 3080

  alias "honk.x61.sh"
  alias "m.x61.sh"
  alias "cloud.x61.sh"

  location "/.well-known/acme-challenge/*" {
     root "/acme"
     request strip 2
  }

  log { access "certs-httpd.log", style combined }

  root "/htdocs"
}

The acme-client will try to renew or generate a certificate of oursites it will ask our DNS, it will hit our public IP through relayd it will match the "certs" rule and it will send the request to the local httpd above, the certificate will be generate or renew in the same server and ready to use by relayd (remember to reload it after it) by the next piece of code:

...
tls keypair "m.x61.sh"
tls keypair "honk.x61.sh"
tls keypair "cloud.x61.sh"

tls { no tlsv1.0, ciphers "HIGH" }
tls ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"
...

At the bottom of it we disable a weak tls version and set most secure ciphers. The final part, the relay, which will forward traffic between a client and a target server.

...
relay "https" {
  listen on egress port 443 tls
  listen on egress port 80

  protocol "http"

  forward to <honk> port 31337 check tcp
  forward to <matrix> port 8448 check tcp
  forward to <certs> port 3080 check tcp
  forward to <cloud> port 80 check tcp
}
...

Our relay is called "https" it will listen on "egress" ports 80 and 443, of course we will use the protocol "http" created previusly. The requests over this relay to each one of the previous matches tagged will be forwarded to the right table over the set port. The forward like will check constantly the port of that server to make sure that it's alive and working, so for example for the <cloud> line, relayd will check the port 80 (by tcp) of the IP 10.10.0.9 in the table <cloud>, if it's alive the request to "cloud.x61.sh" will end up in the right place. Can we check the health of these hosts? Sure:

# relayctl show hosts
Id   Type            Name                     Avlblty Status
1    table           cloud:80                        active (1 hosts)
1    host            10.10.0.9                100.00% up
                     total: 252/252 checks
2    table           honk:31337                      active (1 hosts)
2    host            10.10.0.7                100.00% up
                     total: 252/252 checks
3    table           certs:3080                      active (1 hosts)
3    host            127.0.0.1                100.00% up
                     total: 252/252 checks
4    table           matrix:8448                     active (1 hosts)
4    host            10.10.0.8                100.00% up
                     total: 252/252 checks
# relayctl show relays
Id      Type            Name                            Avlblty Status
1       relay           https                                   active
                        total: 109 sessions
                        last: 1/60s 109/h 109/d sessions
                        average: 2/60s 0/h 0/d sessions
2       relay           https2:80                               active
                        total: 7 sessions
                        last: 0/60s 7/h 7/d sessions
                        average: 0/60s 0/h 0/d sessions

That's all, now you have relayd in front of your cloud managing headers and certs, let's do a fast configtest and enable it on boot time:

# relayd -nf /etc/relayd.conf                                                                                                                      
configuration OK
# rcctl enable relayd
# rcctl start relayd
relayd(ok)

Now you are in trend with your own cloud. If you want to try your headers, you can use this site or this one. Have fun!.