From 59dd0b007a914feae7df0d071734206a819d77d2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 May 2021 15:13:57 +0200 Subject: [PATCH] Update HaProxy document, move server/admin to correct place --- docs/source/Setup/HAProxy-Config.md | 255 +++++++++++++++--- evennia/accounts/accounts.py | 1 + evennia/help/models.py | 2 +- evennia/server/models.py | 10 +- evennia/server/throttle.py | 54 ++-- evennia/typeclasses/tags.py | 2 +- evennia/web/admin/__init__.py | 1 + evennia/web/admin/help.py | 10 +- .../{server/admin.py => web/admin/server.py} | 14 +- 9 files changed, 266 insertions(+), 83 deletions(-) rename evennia/{server/admin.py => web/admin/server.py} (76%) diff --git a/docs/source/Setup/HAProxy-Config.md b/docs/source/Setup/HAProxy-Config.md index daaebc6e83..8e6f11e1d5 100644 --- a/docs/source/Setup/HAProxy-Config.md +++ b/docs/source/Setup/HAProxy-Config.md @@ -1,62 +1,243 @@ -# HAProxy Config (Optional) +## Making Evennia, HTTPS and WSS (Secure Websockets) play nicely together -### Evennia, HTTPS and Secure Websockets can play nicely together, quickly. +A modern public-facing website should these days be served via encrypted +connections. So `https:` rather than `http:` for the website and +`wss:` rather than vs `ws:` for websocket connections used by webclient. -This sets up HAProxy 1.5+ in front of Evennia to provide security. +The reason is security - not only does it make sure a user ends up at the right +site (rather than a spoof that hijacked the original's address), it stops an +evil middleman from snooping on data (like passwords) being sent across the +wire. + +Evennia itself does not implement https/wss connections. This is something best +handled by dedicated tools able to keep up-to-date with the latest security +practices. + +So what we'll do is install _proxy_ between Evennia and the outgoing ports of +your server. Essentially, Evennia will think it's only running locally (on +localhost, IP 127.0.0.1) while the proxy will transparently map that to the +"real" outgoing ports and handle HTTPS/WSS for us. + + Evennia + | + (inside-only local IP/ports serving HTTP/WS) + | + Proxy + | + (outside-visible public IP/ports serving HTTPS/WSS) + | + Firewall + | + Internet + +These instructions assume you run a server with Unix/Linux (very common if you +use remote hosting) and that you have root access to that server. + +The pieces we'll need: + +- [HAProxy](https://www.haproxy.org/) - an open-source proxy program that is + easy to set up and use. +- [LetsEncrypt](https://letsencrypt.org/getting-started/) for providing the User + Certificate needed to establish an encrypted connection. In particular we'll + use the excellent [Certbot](https://certbot.eff.org/instructions) program, + which automates the whole certificate setup process with LetsEncrypt. +- `cron` - this comes with all Linux/Unix systems and allows to automate tasks + in the OS. + +Before starting you also need the following information and setup: + +- (optional) The host name of your game. This is + something you must previously have purchased from a _domain registrar_ and set + up with DNS to point to the IP of your server. For the benefit of this + manual, we'll assume your host name is `my.awesomegame.com`. +- If you don't have a domain name or haven't set it up yet, you must at least + know the IP address of your server. Find this with `ifconfig` or similar from + inside the server. If you use a hosting service like DigitalOcean you can also + find the droplet's IP address in the control panel. Use this as the host name + everywhere. +- You must open port 80 in your firewall. This is used by Certbot below to + auto-renew certificates. So you can't really run another webserver alongside + this setup without tweaking. +- You must open port 443 (HTTPS) in your firewall. This will be the external + webserver port. +- Make sure port 4001 (internal webserver port) is _not_ open in your firewall + (it usually will be closed by default unless you explicitly opened it + previously). +- Open port 4002 in firewall (we'll use the same number for both internal- + and external ports, the proxy will only show the safe one serving wss). + +## Getting certificates + +Certificates guarantee that you are you. Easiest is to get this with +[Letsencrypt](https://letsencrypt.org/getting-started/) and the +[Certbot](https://certbot.eff.org/instructions) program. Certbot has a lot of +install instructions for various operating systems. Here's for Debian/Ubuntu: + + sudo apt install certbot + +Make sure to stop Evennia and that no port-80 using service is running, then + + sudo certbot certonly --standalone + +You will get some questions you need to answer, such as an email to send +certificate errors to and the host name (or IP, supposedly) to use with this +certificate. After this, the certificates will end up in +`/etc/letsencrypt/live//*pem` (example from Ubuntu). The +critical files for our purposes are `fullchain.pem` and `privkey.pem`. + +Certbot sets up a cron-job/systemd job to regularly renew the certificate. To +check this works, try -Installing HAProxy is usually as simple as: ``` -# Redhat derivatives -yum install haproxy -# dnf instead of yum for very recent Fedora distros. -``` -or -``` -# Debian derivatives -apt install haproxy +sudo certbot renew --dry-run + ``` -Configuration of HAProxy requires a single file given as an argument on the command line: -``` -haproxy -f /path/to/config.file -``` +The certificate is only valid for 3 months at a time, so make sure this test +works (it requires port 80 to be open). Look up Certbot's page for more help. -In it (example using haproxy 1.5.18 on Centos7): -``` -# stuff provided by the default haproxy installs +We are not quite done. HAProxy expects these two files to be _one_ file. More +specifically we are going to +1. copy `privkey.pem` and copy it to a new file named `.pem` (like + `my.awesomegame.com.pem`) +2. Append the contents of `fullchain.pem` to the end of this new file. No empty + lines are needed. + +We could do this by copy&pasting in a text editor, but here's how to do it with +shell commands (replace the example paths with your own): + + cd /etc/letsencrypt/live/my.awesomegame.com/ + sudo cp privkey.pem my.awesomegame.com.pem + sudo cat fullchain.pem >> my.awesomegame.com.pem + +The new `my.awesomegame.com.pem` file (or whatever you named it) is what we will +point to in the HAProxy config below. + +There is a problem here though - Certbot will (re)generate `fullchain.pem` for +us automatically a few days before before the 3-month certificate runs out. +But HAProxy will not see this because it is looking at the combined file that +will still have the old `fullchain.pem` appended to it. + +We'll set up an automated task to rebuild the `.pem` file regularly by +using the `cron` program of Unix/Linux. + + crontab -e + +An editor will open to the crontab file. Add the following at the bottom (all +on one line, and change the paths to your own!): + + 0 5 * * * cd /etc/letsencrypt/live/my.awesomegame.com/ && + cp privkey.pem my.awesomegame.com.pem && + cat fullchain.pem >> my.awesomegame.com.pem + +Save and close the editor. Every night at 05:00 (5 AM), the +`my.awesomegame.com.pem` will now be rebuilt for you. Since Certbot updates +the `fullchain.pem` file a few days before the certificate runs out, this should +be enough time to make sure HaProxy never sees an outdated certificate. + +## Installing and configuring HAProxy + +Installing HaProxy is usually as simple as: + + # Debian derivatives (Ubuntu, Mint etc) + sudo apt install haproxy + + # Redhat derivatives (dnf instead of yum for very recent Fedora distros) + sudo yum install haproxy + +Configuration of HAProxy is done in a single file. This can be located wherever +you like, for now put in your game dir and name it `haproxy.cfg`. + +Here is an example tested on Centos7 and Ubuntu. Make sure to change the file to +put in your own values. + +We use the `my.awesomegame.com` example here and here are the ports + +- `443` is the standard SSL port +- `4001` is the standard Evennia webserver port (firewall closed!) +- `4002` is the default Evennia websocket port (we use the same number for + the outgoing wss port, so this should be open in firewall). + +```shell +# base stuff to set up haproxy global log /dev/log local0 chroot /var/lib/haproxy maxconn 4000 user haproxy + tune.ssl.default-dh-param 2048 + ## uncomment this when everything works + # daemon defaults mode http option forwardfor # Evennia Specifics listen evennia-https-website - bind : ssl no-sslv3 no-tlsv10 crt -/path/to/your-cert.pem - server localhost 127.0.0.1: - -listen evennia-secure-websocket - bind : ssl no-sslv3 no-tlsv10 crt /path/to/your- -cert.pem - server localhost 127.0.0.1: + bind my.awesomegame.com:443 ssl no-sslv3 no-tlsv10 crt /etc/letsencrypt/live/my.awesomegame.com>/my.awesomegame.com.pem + server localhost 127.0.0.1:4001 timeout client 10m timeout server 10m + timeout connect 5m + +listen evennia-secure-websocket + bind my.awesomegame.com:4002 ssl no-sslv3 no-tlsv10 crt /etc/letsencrypt/live/my.awesomegame.com/my.awesomegame.com.pem + server localhost 127.0.0.1:4002 + timeout client 10m + timeout server 10m + timeout connect 5m + ``` -Then edit mygame/server/conf/settings.py and add: -``` -WEBSERVER_INTERFACES = ['127.0.0.1'] -WEBSOCKET_CLIENT_INTERFACE = '127.0.0.1' -``` -or -``` -LOCKDOWN_MODE=True -``` +## Putting it all together + +Get back to the Evennia game dir and edit mygame/server/conf/settings.py. Add: + + WEBSERVER_INTERFACES = ['127.0.0.1'] + WEBSOCKET_CLIENT_INTERFACE = '127.0.0.1' + and + + WEBSOCKET_CLIENT_URL="wss://my.awesomegame.com:4002/" + +Make sure to reboot (stop + start) evennia completely: + + evennia reboot + + +Finally you start the proxy: + ``` -WEBSOCKET_CLIENT_URL="wss://yourhost.com:4002/" +sudo haproxy -f /path/to/the/above/haproxy.cfg + ``` + +Make sure you can connect to your game from your browser and that you end up +with an `https://` page and can use the websocket webclient. + +Once everything works you may want to start the proxy automatically and in the +background. Stop the proxy with `Ctrl-C` and make sure to uncomment the line `# +daemon` in the config file. + +If you have no other proxies running on your server, you can copy your +haproxy.conf file to the system-wide settings: + + sudo cp /path/to/the/above/haproxy.cfg /etc/haproxy/ + +The proxy will now start on reload and you can control it with + + sudo service haproxy start|stop|restart|status + +If you don't want to copy stuff into `/etc/` you can also run the haproxy purely +out of your current location by running it with `cron` on server restart. Open +the crontab again: + + sudo crontab -e + +Add a new line to the end of the file: + + @reboot haproxy -f /path/to/the/above/haproxy.cfg + +Save the file and haproxy should start up automatically when you reboot the +server. Next just restart the proxy manually a last time - with `daemon` +uncommented in the config file, it will now start as a background process. diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 328ee6b90d..1d4e461642 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -51,6 +51,7 @@ _MUDINFO_CHANNEL = None _CONNECT_CHANNEL = None _CMDHANDLER = None + # Create throttles for too many account-creations and login attempts CREATION_THROTTLE = Throttle( name='creation', limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT diff --git a/evennia/help/models.py b/evennia/help/models.py index fcae0ca01c..63996c9f44 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -98,7 +98,7 @@ class HelpEntry(SharedMemoryModel): def aliases(self): return AliasHandler(self) - class Meta(object): + class Meta: "Define Django meta options" verbose_name = "Help Entry" verbose_name_plural = "Help Entries" diff --git a/evennia/server/models.py b/evennia/server/models.py index 1dc3924c54..d455c54bc5 100644 --- a/evennia/server/models.py +++ b/evennia/server/models.py @@ -8,9 +8,10 @@ Config values should usually be set through the manager's conf() method. """ -import pickle - from django.db import models +from django.urls import reverse +from django.contrib.contenttypes.models import ContentType + from evennia.utils.idmapper.models import WeakSharedMemoryModel from evennia.utils import logger, utils from evennia.utils.dbserialize import to_pickle, from_pickle @@ -110,7 +111,7 @@ class ServerConfig(WeakSharedMemoryModel): value = property(__value_get, __value_set, __value_del) - class Meta(object): + class Meta: "Define Django meta options" verbose_name = "Server Config value" verbose_name_plural = "Server Config values" @@ -118,9 +119,8 @@ class ServerConfig(WeakSharedMemoryModel): # # ServerConfig other methods # - def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.key, self.value) + return "<{} {}>".format(self.__class__.__name__, self.key) def store(self, key, value): """ diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index c8d8571122..2132a429a6 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -13,7 +13,7 @@ class Throttle(object): This version of the throttle is usable by both the terminal server as well as the web server, imposes limits on memory consumption by using deques - with length limits instead of open-ended lists, and uses native Django + with length limits instead of open-ended lists, and uses native Django caches for automatic key eviction and persistence configurability. """ @@ -37,28 +37,28 @@ class Throttle(object): except Exception as e: logger.log_trace("Throttle: Errors encountered; using default cache.") self.storage = caches['default'] - + self.name = kwargs.get('name', 'undefined-throttle') self.limit = kwargs.get("limit", 5) self.cache_size = kwargs.get('cache_size', self.limit) self.timeout = kwargs.get("timeout", 5 * 60) - + def get_cache_key(self, *args, **kwargs): """ Creates a 'prefixed' key containing arbitrary terms to prevent key collisions in the same namespace. - + """ return '-'.join((self.name, *args)) - + def touch(self, key, *args, **kwargs): """ Refreshes the timeout on a given key and ensures it is recorded in the key register. - + Args: key(str): Key of entry to renew. - + """ cache_key = self.get_cache_key(key) if self.storage.touch(cache_key, self.timeout): @@ -86,11 +86,11 @@ class Throttle(object): keys_key = self.get_cache_key('keys') keys = self.storage.get_or_set(keys_key, set(), self.timeout) data = self.storage.get_many((self.get_cache_key(x) for x in keys)) - + found_keys = set(data.keys()) if len(keys) != len(found_keys): self.storage.set(keys_key, found_keys, self.timeout) - + return data def update(self, ip, failmsg="Exceeded threshold."): @@ -107,14 +107,14 @@ class Throttle(object): """ cache_key = self.get_cache_key(ip) - + # Get current status previously_throttled = self.check(ip) # Get previous failures, if any entries = self.storage.get(cache_key, []) entries.append(time.time()) - + # Store updated record self.storage.set(cache_key, deque(entries, maxlen=self.cache_size), self.timeout) @@ -124,54 +124,54 @@ class Throttle(object): # If this makes it engage, log a single activation event if not previously_throttled and currently_throttled: logger.log_sec(f"Throttle Activated: {failmsg} (IP: {ip}, {self.limit} hits in {self.timeout} seconds.)") - + self.record_ip(ip) - + def remove(self, ip, *args, **kwargs): """ Clears data stored for an IP from the throttle. - + Args: ip(str): IP to clear. - + """ exists = self.get(ip) if not exists: return False - + cache_key = self.get_cache_key(ip) self.storage.delete(cache_key) self.unrecord_ip(ip) - + # Return True if NOT exists return ~bool(self.get(ip)) - + def record_ip(self, ip, *args, **kwargs): """ - Tracks keys as they are added to the cache (since there is no way to + Tracks keys as they are added to the cache (since there is no way to get a list of keys after-the-fact). - + Args: ip(str): IP being added to cache. This should be the original IP, not the cache-prefixed key. - + """ keys_key = self.get_cache_key('keys') keys = self.storage.get(keys_key, set()) keys.add(ip) self.storage.set(keys_key, keys, self.timeout) return True - + def unrecord_ip(self, ip, *args, **kwargs): """ Forces removal of a key from the key registry. - + Args: ip(str): IP to remove from list of keys. - + """ keys_key = self.get_cache_key('keys') keys = self.storage.get(keys_key, set()) - try: + try: keys.remove(ip) self.storage.set(keys_key, keys, self.timeout) return True @@ -194,7 +194,7 @@ class Throttle(object): """ now = time.time() ip = str(ip) - + cache_key = self.get_cache_key(ip) # checking mode @@ -210,4 +210,4 @@ class Throttle(object): self.remove(ip) return False else: - return False \ No newline at end of file + return False diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index ceb0d2f592..3ea9f41ef6 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -75,7 +75,7 @@ class Tag(models.Model): db_index=True, ) - class Meta(object): + class Meta: "Define Django meta options" verbose_name = "Tag" unique_together = (("db_key", "db_category", "db_tagtype", "db_model"),) diff --git a/evennia/web/admin/__init__.py b/evennia/web/admin/__init__.py index 9f0eb5392f..a5b6de9540 100644 --- a/evennia/web/admin/__init__.py +++ b/evennia/web/admin/__init__.py @@ -12,3 +12,4 @@ from .scripts import ScriptAdmin from .comms import ChannelAdmin, MsgAdmin from .help import HelpEntryAdmin from .tags import TagAdmin +from .server import ServerConfigAdmin diff --git a/evennia/web/admin/help.py b/evennia/web/admin/help.py index b82e63707f..6010595987 100644 --- a/evennia/web/admin/help.py +++ b/evennia/web/admin/help.py @@ -16,7 +16,7 @@ class HelpTagInline(TagInline): class HelpEntryForm(forms.ModelForm): "Defines how to display the help entry" - class Meta(object): + class Meta: model = HelpEntry fields = "__all__" @@ -29,9 +29,11 @@ class HelpEntryForm(forms.ModelForm): required=False, widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), help_text="Set lock to view:all() unless you want it to only show to certain users." - "
Use the `edit:` limit if wanting to limit who can edit from in-game. By default it's only " - "limited to who can use the `sethelp` command (Builders).") + "
Use the `edit:` limit if wanting to limit who can edit from in-game. By default it's " + "only limited to who can use the `sethelp` command (Builders).") + +@admin.register(HelpEntry) class HelpEntryAdmin(admin.ModelAdmin): "Sets up the admin manaager for help entries" inlines = [HelpTagInline] @@ -59,5 +61,3 @@ class HelpEntryAdmin(admin.ModelAdmin): }, ), ) - -admin.site.register(HelpEntry, HelpEntryAdmin) diff --git a/evennia/server/admin.py b/evennia/web/admin/server.py similarity index 76% rename from evennia/server/admin.py rename to evennia/web/admin/server.py index efaa347fb6..81ce296f8c 100644 --- a/evennia/server/admin.py +++ b/evennia/web/admin/server.py @@ -1,12 +1,15 @@ -# -# This sets up how models are displayed -# in the web admin interface. -# +""" + +This sets up how models are displayed +in the web admin interface. + +""" from django.contrib import admin from evennia.server.models import ServerConfig +@admin.register(ServerConfig) class ServerConfigAdmin(admin.ModelAdmin): """ Custom admin for server configs @@ -20,6 +23,3 @@ class ServerConfigAdmin(admin.ModelAdmin): save_as = True save_on_top = True list_select_related = True - - -admin.site.register(ServerConfig, ServerConfigAdmin)