Add PureGym server (#22)

This commit is contained in:
Patrick Stevens
2023-12-28 22:18:39 +00:00
committed by GitHub
parent 56483b6b80
commit 3eec70b88d
14 changed files with 328 additions and 51 deletions

View File

@@ -101,6 +101,7 @@ type WellKnownSubdomain =
| Woodpecker
| WoodpeckerAgent
| Grafana
| PureGym
override this.ToString () =
match this with
@@ -111,6 +112,7 @@ type WellKnownSubdomain =
| Grafana -> "grafana"
| Woodpecker -> "woodpecker"
| WoodpeckerAgent -> "woodpecker-agent"
| PureGym -> "puregym"
static member Parse (s : string) =
match s with
@@ -121,6 +123,7 @@ type WellKnownSubdomain =
| "woodpecker" -> WellKnownSubdomain.Woodpecker
| "woodpecker-agent" -> WellKnownSubdomain.WoodpeckerAgent
| "grafana" -> WellKnownSubdomain.Grafana
| "puregym" -> WellKnownSubdomain.PureGym
| _ -> failwith $"Failed to deserialise: {s}"

View File

@@ -1,6 +1,7 @@
{
nixpkgs,
website,
puregym-client,
...
}: let
lib = nixpkgs.lib;
@@ -15,10 +16,11 @@ in {
./gitea/gitea-config.nix
./miniflux/miniflux.nix
./userconfig.nix
./nginx/nginx-config.nix
./nginx/nginx.nix
./woodpecker/woodpecker.nix
./prometheus/prometheus.nix
./grafana/grafana.nix
./puregym/puregym.nix
# generated at runtime by nixos-infect and copied here
./hardware-configuration.nix
./networking.nix
@@ -43,6 +45,10 @@ in {
services.woodpecker-config.admin-users = [userConfig.remoteUsername];
services.grafana-config.domain = userConfig.domain;
services.prometheus-config.domain-exporter-domains = [userConfig.domain];
services.puregym-config.domain = userConfig.domain;
services.puregym-config.subdomain = "puregym";
services.journald.extraConfig = "SystemMaxUse=100M";
system.stateVersion = "23.05";
@@ -63,7 +69,10 @@ in {
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = sshKeys;
virtualisation.docker.enable = true;
virtualisation.docker = {
enable = true;
};
users.extraGroups.docker.members = [userConfig.remoteUsername];
security.pam.loginLimits = [

View File

@@ -2,8 +2,8 @@
"nodes": {
"anki-compiler": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_3"
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1694219801,
@@ -63,6 +63,24 @@
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
@@ -77,9 +95,9 @@
"type": "github"
}
},
"flake-utils_2": {
"flake-utils_3": {
"inputs": {
"systems": "systems_2"
"systems": "systems_3"
},
"locked": {
"lastModified": 1694529238,
@@ -102,11 +120,11 @@
]
},
"locked": {
"lastModified": 1696145345,
"narHash": "sha256-3dM7I/d4751SLPJah0to1WBlWiyzIiuCEUwJqwBdmr4=",
"lastModified": 1703795120,
"narHash": "sha256-Scr4fwfGn03zwFgM7IltT8hqbFDkHvymnF5AaR4eDAg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "6f9b5b83ad1f470b3d11b8a9fe1d5ef68c7d0e30",
"rev": "ba6b75011b44e85b1b755b6c423f85d0817645f7",
"type": "github"
},
"original": {
@@ -128,11 +146,10 @@
"scripts": "scripts_2"
},
"locked": {
"lastModified": 1696175612,
"narHash": "sha256-8V8klzc7T3EdAdS4r8RRjNvTTytQOsvfi7DfK6NFK6M=",
"ref": "refs/heads/main",
"rev": "ac0b0180304bce7683dc8b4466a6e92b339c0b7e",
"revCount": 15,
"dirtyRev": "9e2f5603f1e4e263e73ae0d0ca7c86ae14427c73-dirty",
"dirtyShortRev": "9e2f560-dirty",
"lastModified": 1701513782,
"narHash": "sha256-dDym75Eq6TIw9IrokBWwSoto0/l3nxFGpH4/VZkeqrQ=",
"type": "git",
"url": "file:/Users/patrick/Desktop/website/static-site-images"
},
@@ -169,27 +186,27 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1694859559,
"narHash": "sha256-F3DFxMHFzZxi6uWty3r6rrbEb312S3ozB0Vkh3BAmas=",
"lastModified": 1703467016,
"narHash": "sha256-/5A/dNPhbQx/Oa2d+Get174eNI3LERQ7u6WTWOlR1eQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "697312fb824243bd7bf82d2a3836a11292614109",
"rev": "d02d818f22c777aa4e854efc3242ec451e5d462a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"rev": "697312fb824243bd7bf82d2a3836a11292614109",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1694908564,
"narHash": "sha256-ducA98AuWWJu5oUElIzN24Q22WlO8bOfixGzBgzYdVc=",
"lastModified": 1703351344,
"narHash": "sha256-9FEelzftkE9UaJ5nqxidaJJPEhe9TPhbypLHmc2Mysc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "596611941a74be176b98aeba9328aa9d01b8b322",
"rev": "7790e078f8979a9fcd543f9a47427eeaba38f268",
"type": "github"
},
"original": {
@@ -201,11 +218,26 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1694760568,
"narHash": "sha256-3G07BiXrp2YQKxdcdms22MUx6spc6A++MSePtatCYuI=",
"lastModified": 1703792911,
"narHash": "sha256-BzCq3IiOlTghYtgPngIUnJDeGlRdz4RJGyS9faONrOE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1d17e304ac93dde75178d7ad47abbecc0357c937",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1703134684,
"narHash": "sha256-SQmng1EnBFLzS7WSRyPM9HgmZP2kLJcPAz+Ug/nug6o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "46688f8eb5cd6f1298d873d4d2b9cf245e09e88e",
"rev": "d6863cbcbbb80e71cecfc03356db1cda38919523",
"type": "github"
},
"original": {
@@ -215,7 +247,7 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs_4": {
"locked": {
"lastModified": 1694021185,
"narHash": "sha256-v5Ie83yfsiQgp4GDRZFIsbkctEynfOdNOi67vBH12XM=",
@@ -256,10 +288,30 @@
"type": "github"
}
},
"puregym-client": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1703797686,
"narHash": "sha256-4HZ+uz7LFK+44IzKuLe9lL34Oau/J1Tppmxpe+x5FCw=",
"ref": "refs/heads/main",
"rev": "8ece87ff57b0ae66f38120d8a26b33661625fa61",
"revCount": 5,
"type": "git",
"url": "https://gitea.patrickstevens.co.uk/patrick/puregym-unofficial-dotnet"
},
"original": {
"type": "git",
"url": "https://gitea.patrickstevens.co.uk/patrick/puregym-unofficial-dotnet"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs",
"puregym-client": "puregym-client",
"sops": "sops",
"website": "website"
}
@@ -326,15 +378,15 @@
},
"sops": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"nixpkgs": "nixpkgs_3",
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1695284550,
"narHash": "sha256-z9fz/wz9qo9XePEvdduf+sBNeoI9QG8NJKl5ssA8Xl4=",
"lastModified": 1703387502,
"narHash": "sha256-JnWuQmyanPtF8c5yAEFXVWzaIlMxA3EAZCh8XNvnVqE=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "2f375ed8702b0d8ee2430885059d5e7975e38f78",
"rev": "e523e89763ff45f0a6cf15bcb1092636b1da9ed3",
"type": "github"
},
"original": {
@@ -373,11 +425,26 @@
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"website": {
"inputs": {
"anki-decks": "anki-decks",
"extra-content": "extra-content",
"flake-utils": "flake-utils_2",
"flake-utils": "flake-utils_3",
"images": "images",
"katex": "katex",
"nixpkgs": [
@@ -387,11 +454,11 @@
"scripts": "scripts_4"
},
"locked": {
"lastModified": 1696194988,
"narHash": "sha256-oYUlQCuY0c1B6p3VEVISwVbmMRg1ko0nkG3m7iM5yus=",
"lastModified": 1701514896,
"narHash": "sha256-XDhco86dHsoHzezarG1UQBpsCyZ+AqRY+w+l3g4hL1o=",
"owner": "Smaug123",
"repo": "static-site-pipeline",
"rev": "d459266f21c0b5d512f41b7b56dbcd653a3b9488",
"rev": "b35c219d0e3e93b5bbd52befa486b54fa4e8b710",
"type": "github"
},
"original": {

View File

@@ -1,10 +1,13 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/697312fb824243bd7bf82d2a3836a11292614109";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
website = {
url = "github:Smaug123/static-site-pipeline";
inputs.nixpkgs.follows = "nixpkgs";
};
puregym-client = {
url = "git+https://gitea.patrickstevens.co.uk/patrick/puregym-unofficial-dotnet";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
@@ -18,6 +21,7 @@
sops,
home-manager,
website,
puregym-client,
} @ inputs: let
system = "x86_64-linux";
in {
@@ -26,6 +30,7 @@
specialArgs = {
inherit system;
website = website.packages.${system}.default;
puregym-client = puregym-client.packages.${system}.default;
};
modules = [
(import ./configuration.nix (inputs // {inherit inputs;}))

View File

@@ -14,11 +14,11 @@
subdomain = lib.mkOption {
type = lib.types.str;
example = "rss";
description = lib.mdDoc "Subdomain in which to put Gitea";
description = lib.mdDoc "Subdomain in which to put Miniflux";
};
port = lib.mkOption {
type = lib.types.port;
description = lib.mdDoc "Gitea localhost port";
description = lib.mdDoc "Miniflux localhost port";
default = 8080;
};
};

View File

@@ -0,0 +1,82 @@
{
config,
pkgs,
lib,
puregym-client,
...
}: {
options = {
services.puregym-config = {
domain = lib.mkOption {
type = lib.types.str;
example = "example.com";
description = lib.mdDoc "Top-level domain to configure";
};
subdomain = lib.mkOption {
type = lib.types.str;
example = "puregym";
description = lib.mdDoc "Subdomain in which to put the PureGym server";
};
port = lib.mkOption {
type = lib.types.port;
description = lib.mdDoc "PureGym localhost port to be forwarded";
default = 1735;
};
};
};
config = {
users.users."puregym".extraGroups = [config.users.groups.keys.name];
users.users."puregym".group = "puregym";
users.groups.puregym = {};
users.users."puregym".isSystemUser = true;
systemd.services.puregym-refresh-auth = {
description = "puregym-refresh-auth";
wantedBy = ["multi-user.target"];
path = [puregym-client];
script = builtins.readFile ./refresh-auth.sh;
serviceConfig = {
Restart = "no";
Type = "oneshot";
User = "puregym";
Group = "puregym";
};
environment = {
PUREGYM = "${puregym-client}/bin/PureGym.App";
};
};
systemd.timers.puregym-refresh-auth = {
wantedBy = ["timers.target"];
partOf = ["puregym-refresh-auth.service"];
timerConfig = {
OnCalendar = "monthly";
Unit = "puregym-refresh-auth.service";
};
};
systemd.services.puregym-server = {
description = "puregym-server";
wantedBy = ["multi-user.target"];
wants = ["puregym-refresh-auth.target"];
serviceConfig = {
Restart = "always";
Type = "exec";
User = "puregym";
Group = "puregym";
ExecStart = "${pkgs.python3}/bin/python ${./puregym.py}";
};
environment = {
PUREGYM_CLIENT = "${puregym-client}/bin/PureGym.App";
PUREGYM_PORT = toString config.services.puregym-config.port;
};
};
services.nginx.virtualHosts."${config.services.puregym-config.subdomain}.${config.services.puregym-config.domain}" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:${toString config.services.puregym-config.port}/";
};
};
};
}

View File

@@ -0,0 +1,100 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess
import os
from datetime import datetime, timedelta
from typing import AnyStr, Callable
from urllib.parse import urlparse, parse_qs
from collections import defaultdict
class MyHandler(BaseHTTPRequestHandler):
_cache_result_by_id = {}
_cache_result_by_name = {}
_last_accessed_by_id = defaultdict(lambda: datetime.min)
_last_accessed_by_name = defaultdict(lambda: datetime.min)
def _bad_request(self, text: str, code: int = 400) -> None:
self.send_response(code)
self.send_header('Content-type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(text.encode('utf-8'))
def get_fullness(self, query: dict[AnyStr, list[AnyStr]]) -> None:
desired_gym_name = None
query_gym = query.get("gym_name", None)
if query_gym is not None:
if not len(query_gym) == 1:
self._bad_request('Send only one gym_name')
return
desired_gym_name = query_gym[0]
query_gym = query.get("gym_id", None)
if query_gym is not None:
if desired_gym_name is not None:
self._bad_request('Cannot supply both gym_id and gym_name')
return
if not len(query_gym) == 1:
self._bad_request('Send only one gym_id')
return
try:
desired_gym_id = int(query_gym[0])
except ValueError:
self._bad_request('gym_id did not parse as an int')
return
elif desired_gym_name is None:
# London Oval
desired_gym_id = 19
else:
desired_gym_id = None
if desired_gym_id is not None:
if abs(datetime.now() - self._last_accessed_by_id[desired_gym_id]) > timedelta(seconds=30):
token = subprocess.check_output(['cat', '/tmp/puregym_token']).strip()
output = subprocess.check_output(
[puregym, 'fullness', '--bearer-token', token, '--gym-id', str(desired_gym_id)], text=True,
encoding='utf-8')
output = output.encode('utf-8')
self._cache_result_by_id[desired_gym_id] = output
self._last_accessed_by_id[desired_gym_id] = datetime.now()
else:
output = self._cache_result_by_id[desired_gym_id]
elif desired_gym_name is not None:
if abs(datetime.now() - self._last_accessed_by_name[desired_gym_name]) > timedelta(seconds=30):
token = subprocess.check_output(['cat', '/tmp/puregym_token']).strip()
completed_process = subprocess.run(
[puregym, 'fullness', '--bearer-token', token, '--gym-name', desired_gym_name], text=True,
encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
output = completed_process.stderr + '\n' + completed_process.stdout
output = output.encode('utf-8')
self._cache_result_by_name[desired_gym_name] = output
self._last_accessed_by_id[desired_gym_name] = datetime.now()
else:
output = self._cache_result_by_name[desired_gym_name]
else:
self._bad_request('Logic error: server reached impossible flow', 500)
return
self.send_response(200)
self.send_header('Content-type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(output)
_handlers: dict[str, Callable[["MyHandler", dict[AnyStr, list[AnyStr]]], None]] = {
"/fullness": get_fullness
}
def do_GET(self):
parsed_path = urlparse(self.path)
handler = self._handlers.get(str(parsed_path.path), None)
if handler is None:
self._bad_request(f"Unrecognised endpoint. Available: {' '.join(self._handlers.keys())}")
else:
params = parse_qs(parsed_path.query)
handler(self, params)
if __name__ == '__main__':
puregym = os.environ["PUREGYM_CLIENT"]
port = int(os.environ["PUREGYM_PORT"])
server = HTTPServer(('localhost', port), MyHandler)
server.serve_forever()

View File

@@ -0,0 +1,5 @@
#!/bin/sh
touch /tmp/puregym_token
chmod 600 /tmp/puregym_token
$PUREGYM auth --user-email "$(cat /run/secrets/puregym_email)" --pin "$(cat /run/secrets/puregym_pin)" >/tmp/puregym_token

View File

@@ -14,6 +14,8 @@
"miniflux_admin_password": "ENC[AES256_GCM,data:aXh6cBst5q7hJja5Ew8pg0ZE0c2Beo8sIwWpsuq6L1ENEAtrgfLf4lCE1MYzmmM9qXLt4ax6,iv:fgUW/eRfL7t2ttDdjxaBIGEJLt5o6Vzxv1ibSvh4XiI=,tag:h/IUuMq333LMwYEQJ5N2aQ==,type:str]",
"grafana_admin_password": "ENC[AES256_GCM,data:GOeJiU7YknnOZyBcMYwLfy1T0Ic=,iv:up11zvxz5TuO8i7A7MZ1A6iZMTicbhKKxWRUFrXqy8Y=,tag:Qf5u2mH/S9CM0jLfnUXLKQ==,type:str]",
"grafana_secret_key": "ENC[AES256_GCM,data:Rh3Ecdv51eunkxc+uIdDMMHBpuk=,iv:IzYHWNYZbA6p/X+EhZBfZDlfi7upZV72B6yZVodaZdU=,tag:+c3yB2P8t1JB+VD3sweR1Q==,type:str]",
"puregym_email": "ENC[AES256_GCM,data:lTqBeh13QEersloKfrTH9lhtgkwUg+waCNNyy62xcA==,iv:JbxZg/00ZhCI5hlfJK4X7rQnCmBVjgP8NCBgkswxRjM=,tag:k4cdya5tKwUONfBrfhAaww==,type:str]",
"puregym_pin": "ENC[AES256_GCM,data:T4m8MW25aZI=,iv:Uij/8BAAh+KDl6xuHNyad8tpyzNqWg+nKmy/itwj8Nc=,tag:VOYJxrpsnlTuK9aBURz6xw==,type:str]",
"sops": {
"kms": null,
"gcp_kms": null,
@@ -29,10 +31,10 @@
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhL1FxdG1HM2VYdFNjMzJT\nOUZFNzFIMERNOWFNNWpGM1dVWFNQU3ZCYWhnCkdYZWdsRWpncDAzYTBaRzE5SFNq\nNFJhT3lXTElXVlJBaStaczhoYnorNWMKLS0tIDF1dlg0S1hnSkxjc01XUUVFcnd6\nSXJyL1BGb2JiVUpNK0FoNEo4cGRBL0EKdR+ZKb8hbP0wmjrzc0e3aIG5rGcyHm8g\njPfEtQx1Vt7rLSmWLNbw8tTx/5G3KFR1Bxa2t7pzEocJMDRW1g/gJA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2023-07-24T18:06:45Z",
"mac": "ENC[AES256_GCM,data:BpYJB+D++ZnEsEbUtSySPYxXGVGm11kGhtjekEf9tSD/pco4ErEwNs1O9ersCisyhRgeJ8C5TYiTBUaxvLKYStaoTPGtIYVCWXzBZt+njuebLg7NjUvT4gq2Bf/Qp3yCY5vdOfMsVBkuId+hMA/W6qG0StFCS+0HuBbpHPEnjcQ=,iv:sNJ07nY67Y+GANe2HmQr1ZBqt2r+hgjYz8aUQtgQ/Pc=,tag:zu6uS8WgpkisOy2NGvXq0w==,type:str]",
"lastmodified": "2023-10-20T22:58:38Z",
"mac": "ENC[AES256_GCM,data:u6iSRlskrKPmAZN6jHp/XgNZDZ2WrTQ9MrH5v2TvC1EL+kPKBhj8mD6SOxovRNJZ1qVCW/sYba3vhKxf/K+2itgLBvTLF5V5HE3JII9qy7aXOeCJo+/Wambzy62tYb8rGgFBwPtHMB6tMl2uZDGkAT0PCCn/v/UyyreRIP8ZpWw=,iv:AZ7iHpjis20ulUE3UL24xDbuPQOa2w9FbdPK6O5AmrY=,tag:qpLy4pf9PBI+jkwlHQwnQA==,type:str]",
"pgp": null,
"unencrypted_suffix": "_unencrypted",
"version": "3.7.3"
"version": "3.8.0"
}
}

View File

@@ -19,5 +19,7 @@
"miniflux_admin_password" = {owner = "miniflux";};
"grafana_admin_password" = {owner = "grafana";};
"grafana_secret_key" = {owner = "grafana";};
"puregym_email" = {owner = "puregym";};
"puregym_pin" = {owner = "puregym";};
};
}

View File

@@ -55,13 +55,12 @@
config.services.woodpecker-agents = {
agents = {
podman-agent = {
docker-agent = {
enable = true;
extraGroups = ["podman"];
extraGroups = ["docker"];
environment = {
WOODPECKER_SERVER = "localhost:${toString config.services.woodpecker-config.grpc-port}";
WOODPECKER_BACKEND = "docker";
DOCKER_HOST = "unix:///run/podman/podman.sock";
};
environmentFile = ["/preserve/woodpecker/woodpecker-combined-secrets.txt"];
};
@@ -70,8 +69,8 @@
config.systemd.services.woodpecker-secret = {
description = "ensure woodpecker secrets are in place";
wantedBy = ["multi-user.target" "woodpecker-server.service" "woodpecker-agent-podman-agent.service"];
before = ["woodpecker-server.service" "woodpecker-agent-podman-agent.service"];
wantedBy = ["multi-user.target" "woodpecker-server.service" "woodpecker-agent-docker-agent.service"];
before = ["woodpecker-server.service" "woodpecker-agent-docker-agent.service"];
script = builtins.readFile ./secrets.sh;
serviceConfig = {
Restart = "no";

View File

@@ -50,7 +50,10 @@
<Content Include="Nix\prometheus\prometheus.nix" />
<Content Include="Nix\prometheus\domains.yaml" />
<Content Include="Nix\miniflux\miniflux.nix" />
<None Include="Nix\nginx\nginx-config.nix" />
<None Include="Nix\nginx\nginx.nix" />
<Content Include="Nix\puregym\puregym.nix" />
<Content Include="Nix\puregym\refresh-auth.sh" />
<Content Include="Nix\puregym\puregym.py" />
<Content Include="config.schema.json" />
<Content Include="waitforready.sh">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>