Bootstrap
Committed 3cd93f
index 0000000..27e538a
--- /dev/null
+name: Run Tests and Analysis
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build-linux:
+ strategy:
+ matrix:
+ python-version: ["3.10"]
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: Install graphviz
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y graphviz
+
+ - uses: actions/checkout@v3
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Remove development dependencies
+ run: sed -i '/\[tool.poetry.group.dev.dependencies\]/,/\[/d' pyproject.toml
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.2.2
+ virtualenvs-in-project: true
+
+ - name: Install dependencies
+ run: poetry install --no-interaction --no-root
+
+ - name: Install library
+ run: poetry install --no-interaction
+
+ - name: Install development tools
+ run: poetry add gmpg
+
+ - uses: psf/black@stable
+ with:
+ options: "--check --verbose"
+ src: "."
+ version: "23.7"
+
+ - uses: isort/isort-action@v1
+ with:
+ configuration: "--profile black"
+
+ - run: echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
+ - uses: jakebailey/pyright-action@v1
+
+ # - name: Run tests
+ # run: poetry run gmpg test
+
+ - name: Run analysis
+ run: poetry run gmpg analyze
+
+ - name: Generate dependency graph
+ run: poetry run gmpg graph
+
+ - uses: actions/upload-artifact@v3
+ with:
+ name: analysis
+ path: |
+ test_coverage.xml
+ test_results.xml
+ api_python.json
+ deps.svg
index 0000000..2f6d376
--- /dev/null
+[tool.poetry]
+name = "webint-sites"
+version = "0.0.2"
+description = "manage sites from your website"
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "AGPL-3.0-or-later"
+packages = [{include="webint_sites"}]
+
+[tool.poetry.plugins."webapps"]
+sites = "webint_sites:app"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.11"
+webint = ">=0.0"
+
+[tool.poetry.group.dev.dependencies]
+gmpg = {path="../gmpg", develop=true}
+bgq = {path="../bgq", develop=true}
+newmath = {path="../newmath", develop=true}
+sqlyte = {path="../sqlyte", develop=true}
+webagt = {path="../webagt", develop=true}
+webint = {path="../webint", develop=true}
+micropub = {path="../python-micropub", develop=true}
+
+# [[tool.poetry.source]]
+# name = "main"
+# url = "https://ragt.ag/code/pypi"
+
+[tool.pyright]
+reportGeneralTypeIssues = false
+reportOptionalMemberAccess = false
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
index 0000000..0f9c0c3
--- /dev/null
+"""Manage sites from your website."""
+
+import web
+from web import host, tx
+
+app = web.application(
+ __name__,
+ prefix="sites",
+ args={"machine": r"\w+", "domain_name": r"\w+"},
+ model={
+ "machines": {
+ "name": "TEXT UNIQUE",
+ "ip_address": "TEXT UNIQUE",
+ "details": "JSON",
+ },
+ "domains": {
+ "name": "TEXT UNIQUE",
+ "nameserver": "TEXT UNIQUE",
+ "details": "JSON",
+ },
+ },
+)
+
+
+def spawn_machine(name, config):
+ """Spin up a VPS and setup a machine."""
+ machine, secret = web.host.setup_website(
+ name, "canopy-platform", "canopy:app", config
+ )
+ print(machine, secret)
+ tx.db.insert("machines", name=name, ip_address=machine.address, details={})
+
+
+def build(ip_address, program):
+ details = tx.db.select("machines", where="ip_address = ?", vals=[ip_address])[0][
+ "details"
+ ]
+ details[program] = getattr(host, f"setup_{program}")(ip_address)
+ tx.db.update("machines", where="ip_address = ?", vals=[ip_address], details=details)
+
+
+@app.control("")
+class Sites:
+ """Manage your websites."""
+
+ owner_only = ["get"]
+
+ def get(self):
+ machines = tx.db.select("machines")
+ return app.view.index(machines)
+
+
+@app.control("gaea")
+class Gaea:
+ """"""
+
+ def get(self):
+ # XXX config = host.get_config()
+ # XXX if web.tx.request.headers["accept"] == "application/json":
+ # XXX return config
+
+ # domains = []
+ # if "registrar" in config:
+ # registrar = dict(config["registrar"])
+ # clients = {
+ # "dynadot.com": host.providers.Dynadot,
+ # "name.com": host.providers.NameCom,
+ # }
+ # domains = clients[registrar.pop("provider")](**registrar).list_domains()
+ # dns = {}
+ # if "domain" in config:
+ # dns["ns"] = [
+ # str(ns)
+ # for ns in web.dns.resolve(config["domain"], "NS").rrset.items.keys()
+ # ]
+ # dns["a"] = web.dns.resolve(config["domain"], "A")[0].address
+ return app.view.gaea() # config) # , domains, dns)
+
+ def post(self):
+ # form = web.form("subdomain", "name")
+ form = web.form("provider", "token")
+ config = host.get_config()
+
+ # domain = config["domain"]
+ # fqdn = domain
+ # if form.subdomain:
+ # fqdn = f"{form.subdomain}.{domain}"
+ # update_config(fqdn=fqdn)
+
+ if form.provider == "digitalocean.com":
+ c = host.digitalocean.Client(form.token)
+ try:
+ c.get_keys()
+ except host.digitalocean.TokenError:
+ return {"status": "error", "message": "bad token"}
+ elif form.provider == "linode.com":
+ ...
+ elif form.provider == "hetzner.com":
+ ...
+ else:
+ return {
+ "status": "error",
+ "message": f"unsupported provider: {form.provider}",
+ }
+ config = host.update_config(
+ host={
+ "provider": form.provider,
+ "token": form.token,
+ }
+ )
+
+ # try:
+ # ip_address = config["ip_address"]
+ # except KeyError:
+ ip_address = host.spawn_machine("canopy", config["host"]["token"])
+ config = host.update_config(ipAddress=ip_address, status={})
+
+ # registrar = config["registrar"]
+ # if registrar["provider"] == "dynadot.com":
+ # dynadot = host.providers.Dynadot(registrar["token"])
+ # dynadot.create_record(domain, ip_address, form.subdomain)
+ # elif registrar["provider"] == "name.com":
+ # namecom = host.providers.NameCom(registrar["username"],
+ # registrar["token"])
+ # namecom.create_record(domain, ip_address, form.subdomain)
+
+ host.setup_machine(ip_address)
+ config = host.update_config(system_setup=True)
+ host.setup_nginx(ip_address)
+ config = host.update_config(nginx_installed=True)
+ return
+ host.generate_dhparam(ip_address)
+ host.setup_tor(ip_address)
+ host.setup_python(ip_address)
+ host.setup_supervisor(ip_address)
+
+ # while True:
+ # try:
+ # if web.dns.resolve(fqdn, "A")[0].address == ip_address:
+ # break
+ # except (
+ # web.dns.NoAnswer,
+ # web.dns.NXDOMAIN,
+ # web.dns.Timeout,
+ # web.dns.NoNameservers,
+ # ):
+ # pass
+ # else:
+ # print("waiting for DNS changes to propagate..")
+ # time.sleep(15)
+
+ onion, passphrase = setup_canopy(ip_address) # , form.name, fqdn)
+ update_config(onion=onion, passphrase=passphrase)
+ return {"onion": onion, "passphrase": passphrase}
+
+
+@app.control("machines")
+class Machines:
+ """Manage your machines."""
+
+ def get(self):
+ return app.view.machines()
+
+ def post(self):
+ name = web.form("name").name
+ config = {
+ "host": "digitalocean",
+ "host_token": tx.app.cfg["DIGITALOCEAN_TOKEN"],
+ }
+ # token = tx.db.select(
+ # "providers", where="service = ?", vals=["digitalocean.com"]
+ # )[0]["token"]
+ web.enqueue(spawn_machine, name, config)
+ raise web.Accepted("machine is being created..")
+
+
+@app.control("machines/{machine}")
+class Machine:
+ """Manage one of your machines."""
+
+ def get(self, machine):
+ details = tx.db.select("machines", where="name = ?", vals=[machine])[0]
+ # try:
+ # token = get_dynadot_token(tx.db)
+ # except IndexError:
+ # domains = None
+ # else:
+ # domains = host.dynadot.Client(token).list_domain()
+ root_ssh = web.host.Machine(details["ip_address"], "root", "gaea_key").run
+ gaea_ssh = web.host.Machine(details["ip_address"], "root", "gaea_key").run
+ return app.view.machine(details, root_ssh, gaea_ssh)
+
+
+@app.control("machines/{machine}/update")
+class MachineBuild:
+ """Manage your machine's system updates."""
+
+ def get(self):
+ ...
+
+ def post(self):
+ machine = tx.db.select("machines", where="name = ?", vals=[self.machine])[0]
+ web.enqueue(host.upgrade_system, machine["ip_address"])
+ raise web.Accepted("system is updating on the machine..")
+
+
+@app.control("machines/{machine}/builds")
+class MachineBuild:
+ """Manage your machine's builds."""
+
+ def get(self):
+ ...
+
+ def post(self):
+ program = web.form("program").program
+ machine = tx.db.select("machines", where="name = ?", vals=[self.machine])[0]
+ web.enqueue(build, machine["ip_address"], program)
+ raise web.Accepted(f"{program} is building on the machine..")
+
+
+@app.control("domains")
+class Domains:
+ """Manage your domains."""
+
+ def get(self):
+ return app.view.domains()
+
+ def post(self):
+ name = web.form("name").name
+ token = tx.db.select("providers", where="service = ?", vals=["dynadot.com"])[0][
+ "token"
+ ]
+ # XXX web.enqueue(spawn_machine, name, token)
+ raise web.Created("domain has been added..", f"/sites/domains/{name}")
+
+
+@app.control("domains/{domain_name}")
+class Domain:
+ """Manage one of your domains."""
+
+ def get(self):
+ domain = tx.db.select("domains", where="name = ?", vals=[self.domain_name])[0]
+ return app.view.domain(domain)
index 0000000..9f63652
--- /dev/null
+from web import tx
+
+__all__ = ["tx"]
index 0000000..4fa1091
--- /dev/null
+$def with ()
+$# , domains, dns)
+<!doctype html>
+<html>
+<head>
+<meta charset=utf-8>
+<title>Spawn your personal website..</title>
+<link rel=stylesheet href=/static/screen.css>
+</head>
+<body>
+<h1>Spawn your personal website..</h1>
+<p>This installer will walk you through setting up your personal website.</p>
+
+$# $if "machine" in config:
+$# <p>Machine: $config["machine"] <button>Delete</button></p>
+$# $else:
+ <form id=spawn>
+ <!--h3><input type=radio name=mode value=automatic checked disabled> Automatic</h3-->
+ <h2>Choose a host</h2>
+ <h3>In the cloud</h3>
+ <p>A cloud host is an organization that will manage an internet-connected
+ machine on your behalf typically for a monthly fee.</p>
+
+ <div id=hosts style="display:grid;grid-template-columns:50% 50%;">
+ <div>
+ <ul>
+ <li><label><input required type=radio name=provider value=digitalocean.com> DigitalOcean
+ <small><a href=https://digitalocean.com
+ target=_blank>digitalocean.com</a></small></label></li>
+ <li><label><input required type=radio name=provider value=linode.com> Linode
+ <small><a href=https://linode.com
+ target=_blank>linode.com</a></small></label></li>
+ <li><label><input required type=radio name=provider value=hetzner.com> Hetzner
+ <small><a href=https://hetzner.com
+ target=_blank>hetzner.com</a></small></label></legend></li>
+ </ul>
+ <label>API Token<br>
+ <label class=bounding><input required type=text name=token></label></label>
+ </div>
+
+ <div><!-- style=break-inside:avoid-->
+ <p id=digitalocean>Go to the <a
+ href=https://cloud.digitalocean.com/account/api/tokens target=_blank>Applications
+ & API</a> page and generate a new personal access token with write scope.</p>
+ <p id=linode>Go to the <a
+ href=https://cloud.linode.com/profile/tokens target=_blank>Tokens</a>
+ page and create a new personal access token with full read/write access.</p>
+ <p id=hetzner>Go to the <a
+ href=https://console.hetzner.cloud/projects target=_blank>Projects</a> page,
+ create a new project, go to that project's page, click on "Security" →
+ "API Tokens" and generate a new token with read/write permission.</p>
+ </div>
+ </div>
+
+ <!--h3><input type=radio name=mode value=manual disabled> Manual</h3>
+ <p>The machine should be a fresh install of vanilla Debian or Raspberry Pi OS
+ (formerly Raspbian).</p>
+
+ <label>IP Address
+ <small>of Raspberry Pi / Virtual Machine / localhost / remote</small><br>
+ <label class=bounding><input type=text name=host_ip disabled></label></label><br>
+ <label>Root Passphrase<br>
+ <label class=bounding><input type=text name=root_passphrase disabled></label>
+ </label-->
+
+ <div class=buttons><button>Spawn</button></div>
+ </form>
+ <div id=progress>
+ <p id=machine_spawned></p>
+ <p id=system_setup></p>
+ <p id=nginx_setup></p>
+ </div>
+ <p id=status></p>
+
+$# $if "registrar" in config:
+$# <p>Registrar: $config["registrar"]["provider"]</p>
+$# $else:
+$# <form id=registrar>
+$# <fieldset><legend><label><input type=checkbox name=use_domain checked> Domain Name <small>(optional)</small></label></legend>
+$# <p>A domain name registrar is an organization that will register domain names
+$# for you typically for an annual fee.</p>
+$# <p><small>Note: in order to use a domain name your machine must be
+$# publicly reachable.</small></p>
+$# <ul>
+$# <li><label><input type=radio name=provider value=dynadot.com> Dynadot
+$# <small><a href=https://dynadot.com>dynadot.com</a></small></label></li>
+$# <li><label><input type=radio name=provider value=name.com> Name
+$# <small><a href=https://name.com>name.com</a></small></label></li>
+$# </ul>
+$# <p id=dynadot>Go to the <a
+$# href=https://www.dynadot.com/account/domain/setting/api.html target=_blank>API</a>
+$# page, unlock your account and generate a new key.</p>
+$# <p id=namecom>Go to the <a
+$# href=https://www.name.com/account/settings/api target=_blank>API</a>
+$# page, agree to the terms if you haven't already and generate a new token.</p>
+$# <label id=username class=field>Username: <label class=bounding><input
+$# type=text name=username></label></label>
+$# <label id=token class=field>Token/Key: <label class=bounding><input
+$# type=text name=token></label></label>
+$# <div class=buttons><button>Save</button></div>
+$# </fieldset>
+
+$# $if "domain" in config:
+$# $if not dns["ns"][0].endswith(config["registrar"]["provider"] + "."):
+$# <p>Your DNS is being managed off-registrar... not supported yet.</p>
+$# <form id=create>
+$# <fieldset><legend>Site Details</legend>
+$# <p>Domain<br>
+$# <input type=text name=domain value=$config["domain"] disabled></p>
+$# <p><label>Subdomain <small>(optional)</small><br><label
+$# class=bounding><input name=subdomain></label></label></p>
+$# <label class=field>Your display name<br><label class=bounding><input type=text
+$# name=name></label></label>
+$# <div class=buttons><button>Create</button></div>
+$# </fieldset>
+$# </form>
+$# $else:
+$# <form id=domain>
+$# <select name=domain>
+$# $for domain, expiration in domains:
+$# <option value=$domain>$domain</option>
+$# </select>
+$# <div class=buttons><button>Choose</button></div>
+$# </form>
+
+<script src=/static/main.js></script>
+</body>
+</html>
index 0000000..77760ad
--- /dev/null
+$def with (machines)
+$var title: Sites
+
+<p>list domains/apps and the machine they're on</p>
+
+$def aside():
+ $if "DIGITALOCEAN_TOKEN" in tx.app.cfg:
+ <h4>Machines</h4>
+ <ul id=machines>
+ $for machine in machines:
+ <li><a href=/sites/machines/$machine["name"]>$machine["name"]</a><br>
+ <small>$machine["ip_address"]</small></li>
+ </ul>
+ <form method=post action=/sites/machines>
+ <label><small>Name</small><br>
+ <label class=bounding><input type=text name=name></label></label>
+ <div class=buttons><button>Spawn Machine</button></div>
+ </form>
+ $else:
+ <p>Add <code style="background-color:#073642;border-radius:.25em;font-weight:bold;padding:0 .25em .125em .25em;"><small><small>DIGITALOCEAN_TOKEN</small></small></code> to <a
+ href=/system#settings>system settings</a></p>
+$var aside = aside
+
+<style>
+ul#machines {
+ list-style: none;
+ padding-left: 0; }
+</style>
index 0000000..064428b
--- /dev/null
+$def with (machine, root_ssh, gaea_ssh)
+$var breadcrumbs = ("machines", "Machines")
+$var title: $machine["name"]
+
+<p>$machine["ip_address"]</p>
+
+$ d = machine["details"]
+$# <p>$d</p>
+
+$code:
+ def get_output(cmd):
+ output = root_ssh(cmd).stdout
+ if "\n" not in output:
+ output += "\n"
+ return output.rpartition("\n")[0]
+
+ def get_gaea_output(cmd):
+ output = gaea_ssh(cmd).stdout
+ if "\n" not in output:
+ output += "\n"
+ return output.rpartition("\n")[0]
+
+$def render(cmd):
+ <pre style=font-size:.8em>$get_output(cmd)</pre>
+
+$def gaea_render(cmd):
+ <pre style=font-size:.8em>$get_gaea_output(cmd)</pre>
+
+<h3>Application</h3>
+$:render("/home/gaea/runinenv /home/gaea/app python -V")
+$:render("du -sh /home/gaea/app")
+<pre style=font-size:.8em>\
+$for line in get_output("ls -lthr /home/gaea/app/run | cut -d' ' -f5-").splitlines():
+ $if not line: $continue
+ $ size, month, day, yeartime, path = line.split()
+ $month $day $yeartime \
+ $if size:
+ <a href=/machines/dev/$path>\
+ $path\
+ $if size:
+ </a>\
+ <small>$size</small>
+</pre>
+
+<h4>Supervisor</h4>
+$:render("supervisorctl status")
+
+$def render_status(cmd):
+ $ status = {}
+ $for line in root_ssh(cmd).stdout.rpartition("\n")[0].splitlines():
+ $ key, _, value = line.partition("=")
+ $ status[key] = value
+ <pre style=font-size:.8em>$status["ActiveState"]</pre>
+
+<h3>Nginx</h3>
+$:render_status("systemctl --no-pager show nginx")
+
+<h3>Tmux</h3>
+$:gaea_render("tmux list-sessions")
+$:gaea_render("tmux list-windows -t 0")
+
+<h3>System</h3>
+$:render("df -ht ext4")
+$:render("grep 'Invalid user' /var/log/auth.log | cut -d' ' -f8 | sort | uniq -c | sort -r | head")
+
+$# <p>Last updated: $d.get("updated")</p>
+$# <form method=post action=/sites/machines/$machine["name"]/update>
+$# <div class=buttons><button>Update System</button></div>
+$# </form>
+$#
+$# $if d.get("supervisor"):
+$# <p>Supervisor: $d["supervisor"]</p>
+$# $else:
+$# <form method=post action=/sites/machines/$machine["name"]/builds>
+$# <input type=hidden name=program value=supervisor>
+$# <div class=buttons><button>Install Supervisor</button></div>
+$# </form>
+$#
+$# $if d.get("nginx"):
+$# <p>Nginx: $d["nginx"]</p>
+$# $else:
+$# <form method=post action=/sites/machines/$machine["name"]/builds>
+$# <input type=hidden name=program value=nginx>
+$# <div class=buttons><button>Install Nginx</button></div>
+$# </form>
+$#
+$# $if d.get("tor"):
+$# <p>Tor: $d["tor"]</p>
+$# $else:
+$# <form method=post action=/sites/machines/$machine["name"]/builds>
+$# <input type=hidden name=program value=tor>
+$# <div class=buttons><button>Install Tor</button></div>
+$# </form>
+$#
+$# $if d.get("python"):
+$# <p>Python: $d["python"]</p>
+$# $else:
+$# <form method=post action=/sites/machines/$machine["name"]/builds>
+$# <input type=hidden name=program value=python>
+$# <div class=buttons><button>Install Python</button></div>
+$# </form>
+$#
+$# <hr>
+$#
+$# $if d.get("canopy"):
+$# <p>Canopy: $d["canopy"]</p>
+$# <h4>Identities</h4>
+$# $# <ul id=domains>
+$# $# $for domain in domains:
+$# $# <li><a href=/sites/domains/$domain["name"]>$domain["name"]</a><br>
+$# $# <small>$domain</small></li>
+$# $# </ul>
+$# <form method=post>
+$# <label><small>Address</small><br>
+$# <label class=bounding><input type=radio> <input type=text
+$# name=subdomain> . <select name=name>
+$# $# $for domain, expiration in domains:
+$# $# <option value="$domain">$domain</option>
+$# </select></label></label>
+$# <label class=bounding><input type=radio> onion</label>
+$# <div class=buttons><button>Add Identity</button></div>
+$# </form>
+$# $else:
+$# <form method=post action=/sites/machines/$machine["name"]/builds>
+$# <input type=hidden name=program value=canopy>
+$# <div class=buttons><button>Install Canopy</button></div>
+$# </form>
index 0000000..ae10c48
--- /dev/null
+$def with (machines)
+$var title: Machines
+
+$machines