diff --git a/.github/workflows/deploy-preview-to-neocities.yml b/.github/workflows/deploy-preview.yml similarity index 65% rename from .github/workflows/deploy-preview-to-neocities.yml rename to .github/workflows/deploy-preview.yml index 9238a13..65658db 100644 --- a/.github/workflows/deploy-preview-to-neocities.yml +++ b/.github/workflows/deploy-preview.yml @@ -7,7 +7,7 @@ on: - preview concurrency: # prevent concurrent deploys doing strange things - group: deploy-preview-to-neocities + group: deploy-preview cancel-in-progress: true # Default to bash @@ -29,8 +29,19 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + - name: Connect to Tailscale + uses: tailscale/github-action@v2 + with: + oauth-client-id: ${{ secrets.TS_API_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_API_CLIENT_SECRET }} + tags: ${{ secrets.TS_TAG }} + - name: Configure SSH known hosts + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - name: Build with Hugo - run: hugo --minify --environment preview + run: HUGO_REMOTE_FONT_PATH=${{ secrets.REMOTE_FONT_PATH }} hugo --minify --environment preview - name: Insert 404 page run: | cp public/404/index.html public/not_found.html diff --git a/.github/workflows/deploy-to-prod.yml b/.github/workflows/deploy-prod.yml similarity index 84% rename from .github/workflows/deploy-to-prod.yml rename to .github/workflows/deploy-prod.yml index 628ef3e..341fb88 100644 --- a/.github/workflows/deploy-to-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -10,7 +10,7 @@ on: - main concurrency: # prevent concurrent deploys doing strange things - group: deploy-to-prod + group: deploy-prod cancel-in-progress: true # Default to bash @@ -32,8 +32,19 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + - name: Connect to Tailscale + uses: tailscale/github-action@v2 + with: + oauth-client-id: ${{ secrets.TS_API_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_API_CLIENT_SECRET }} + tags: ${{ secrets.TS_TAG }} + - name: Configure SSH known hosts + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - name: Build with Hugo - run: hugo --minify + run: HUGO_REMOTE_FONT_PATH=${{ secrets.REMOTE_FONT_PATH }} hugo --minify - name: Insert 404 page run: | cp public/404/index.html public/not_found.html @@ -47,18 +58,6 @@ jobs: api_token: ${{ secrets.NEOCITIES_API_TOKEN }} cleanup: true dist_dir: public - - name: Connect to Tailscale - uses: tailscale/github-action@v2 - with: - oauth-client-id: ${{ secrets.TS_API_CLIENT_ID }} - oauth-secret: ${{ secrets.TS_API_CLIENT_SECRET }} - tags: ${{ secrets.TS_TAG }} - - name: Install SSH key - uses: shimataro/ssh-key-action@v2 - with: - key: ${{ secrets.SSH_KEY }} - name: id_rsa - known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - name: Deploy GMI to Agate run: | rsync -avz --delete --exclude='*.html' --exclude='*.css' --exclude='*.js' -e ssh public/ deploy@${{ secrets.GMI_HOST }}:${{ secrets.GMI_CONTENT_PATH }} diff --git a/archetypes/default.md b/archetypes/default.md index f57f054..57e2c61 100644 --- a/archetypes/default.md +++ b/archetypes/default.md @@ -12,6 +12,7 @@ tags: - android - caddy - chromeos + - cloudflare - crostini - docker - gcp diff --git a/assets/FiraMono-Regular.ttf b/assets/FiraMono-Regular.ttf deleted file mode 100644 index 59e1e1a..0000000 Binary files a/assets/FiraMono-Regular.ttf and /dev/null differ diff --git a/assets/og_base.png b/assets/og_base.png index d535d82..7c6da5f 100644 Binary files a/assets/og_base.png and b/assets/og_base.png differ diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml index 95426b9..a8c8ff4 100644 --- a/config/_default/hugo.toml +++ b/config/_default/hugo.toml @@ -6,6 +6,7 @@ paginate = 10 languageCode = "en" DefaultContentLanguage = "en" enableInlineShortcodes = true +enableRobotsTXT = true # define gemini media type [mediaTypes] diff --git a/config/_default/params.toml b/config/_default/params.toml index 7343f68..1f8c385 100644 --- a/config/_default/params.toml +++ b/config/_default/params.toml @@ -8,6 +8,34 @@ numberOfRelatedPosts = 5 indexTitle = ".-. ..- -. - .. -- . - . .-. .-. --- .-." +robots = [ + "AdsBot-Google", + "Amazonbot", + "anthropic-ai", + "Applebot", + "AwarioRssBot", + "AwarioSmartBot", + "Bytespider", + "CCBot", + "ChatGPT", + "ChatGPT-User", + "Claude-Web", + "ClaudeBot", + "cohere-ai", + "DataForSeoBot", + "Diffbot", + "FacebookBot", + "Google-Extended", + "GPTBot", + "ImagesiftBot", + "magpie-crawler", + "omgili", + "Omgilibot", + "peer39_crawler", + "PerplexityBot", + "YouBot" +] + # Comments comments = true giscusCategory = "Announcements" @@ -60,10 +88,12 @@ taglines = [ "creating new and exciting bugs", "cyclic dependency detected", "destructor cannot be overloaded", + "did the needful", "directory not empty", "division by zero", "error: could not stat or open file", "expression has no effect", + "failed successfully", "file descriptor in bad state", "floating in a sea of bugs", "from chatgpt with bugs", @@ -79,6 +109,7 @@ taglines = [ "i opened vi and i can't get out", "i see null pointers", "i'd tell you a udp joke but", + "if err == nil { panic(\"that should not have worked\") }", "i'm in ur codez, fixin ur bugz", "i'm not a real programmer", "i'm the one who debugs", @@ -88,6 +119,7 @@ taglines = [ "it's not a bug, it's a feature in search of a use case", "keep your friends close, but your breakpoints closer", "kernel panic - not syncing: attempted to kill init", + "long division took too long", "may the code be with you", "mess with the test, fail like the rest", "need input", @@ -142,6 +174,11 @@ icon = "fa-solid fa-heart" title = "omg.lol" url = "https://jbowdre.lol" +[[socialLinks]] +icon = "fa-solid fa-sticky-note" +title = "Scribbles 'n Bits" +url = "https://scribbles.jbowdre.lol" + [[socialLinks]] icon = "fa-solid fa-satellite" title = "Gemlog" diff --git a/content/about.md b/content/about.md index 675f575..91ccdc7 100644 --- a/content/about.md +++ b/content/about.md @@ -23,6 +23,7 @@ And in the free time I have left, I game on my Steam Deck. ### See what I've been up to on: - [GitHub](https://github.com/jbowdre) +- [Scribbles 'n Bits](https://scribbles.jbowdre.lol) - [Gemlog](https://capsule.jbowdre.lol/gemlog/) - [status.lol](https://status.jbowdre.lol) - [social.lol](https://social.lol/@jbowdre) diff --git a/content/posts/blocking-ai-crawlers/cloudflare-waf-rule.png b/content/posts/blocking-ai-crawlers/cloudflare-waf-rule.png new file mode 100644 index 0000000..af74436 Binary files /dev/null and b/content/posts/blocking-ai-crawlers/cloudflare-waf-rule.png differ diff --git a/content/posts/blocking-ai-crawlers/cloudflare-waf-status.png b/content/posts/blocking-ai-crawlers/cloudflare-waf-status.png new file mode 100644 index 0000000..76d4615 Binary files /dev/null and b/content/posts/blocking-ai-crawlers/cloudflare-waf-status.png differ diff --git a/content/posts/blocking-ai-crawlers/index.md b/content/posts/blocking-ai-crawlers/index.md new file mode 100644 index 0000000..71ecd84 --- /dev/null +++ b/content/posts/blocking-ai-crawlers/index.md @@ -0,0 +1,142 @@ +--- +title: "Blocking AI Crawlers" +date: 2024-04-12 +lastmod: "2024-04-14T02:21:57Z" +description: "Using Hugo to politely ask AI bots to not steal my content - and then configuring Cloudflare's WAF to actively block them, just to be sure." +featured: false +toc: true +comments: true +categories: Backstage +tags: + - cloud + - cloudflare + - hugo + - meta + - selfhosting +--- +I've seen some recent posts from folks like [Cory Dransfeldt](https://coryd.dev/posts/2024/go-ahead-and-block-ai-web-crawlers/) and [Ethan Marcotte](https://ethanmarcotte.com/wrote/blockin-bots/) about how (and *why*) to prevent your personal website from being slurped up by the crawlers that AI companies use to [actively enshittify the internet](https://boehs.org/node/llms-destroying-internet). I figured it was past time for me to hop on board with this, so here we are. + +My initial approach was to use [Hugo's robots.txt templating](https://gohugo.io/templates/robots/) to generate a `robots.txt` file based on a list of bad bots I got from [ai.robots.txt on GitHub](https://github.com/ai-robots-txt/ai.robots.txt). + +I dumped that list into my `config/params.toml` file, *above* any of the nested elements (since toml is kind of picky about that...). + +```toml +robots = [ + "AdsBot-Google", + "Amazonbot", + "anthropic-ai", + "Applebot", + "AwarioRssBot", + "AwarioSmartBot", + "Bytespider", + "CCBot", + "ChatGPT", + "ChatGPT-User", + "Claude-Web", + "ClaudeBot", + "cohere-ai", + "DataForSeoBot", + "Diffbot", + "FacebookBot", + "Google-Extended", + "GPTBot", + "ImagesiftBot", + "magpie-crawler", + "omgili", + "Omgilibot", + "peer39_crawler", + "PerplexityBot", + "YouBot" +] + +[author] +name = "John Bowdre" +``` + +I then created a new template in `layouts/robots.txt`: + +```text +Sitemap: {{ .Site.BaseURL }}/sitemap.xml + +User-agent: * +Disallow: +{{ range .Site.Params.robots }} +User-agent: {{ . }} +{{- end }} +Disallow: / +``` + +And enabled the template processing for this in my `config/hugo.toml` file: + +```toml +enableRobotsTXT = true +``` + +Now Hugo will generate the following `robots.txt` file for me: + +```text +Sitemap: https://runtimeterror.dev//sitemap.xml + +User-agent: * +Disallow: + +User-agent: AdsBot-Google +User-agent: Amazonbot +User-agent: anthropic-ai +User-agent: Applebot +User-agent: AwarioRssBot +User-agent: AwarioSmartBot +User-agent: Bytespider +User-agent: CCBot +User-agent: ChatGPT +User-agent: ChatGPT-User +User-agent: Claude-Web +User-agent: ClaudeBot +User-agent: cohere-ai +User-agent: DataForSeoBot +User-agent: Diffbot +User-agent: FacebookBot +User-agent: Google-Extended +User-agent: GPTBot +User-agent: ImagesiftBot +User-agent: magpie-crawler +User-agent: omgili +User-agent: Omgilibot +User-agent: peer39_crawler +User-agent: PerplexityBot +User-agent: YouBot +Disallow: / +``` + +Cool! + +I also dropped the following into `static/ai.txt` for [good measure](https://site.spawning.ai/spawning-ai-txt): + +```text +# Spawning AI +# Prevent datasets from using the following file types + +User-Agent: * +Disallow: / +Disallow: * +``` + +That's all well and good, but these files carry all the weight and authority of a "No Soliciting" sign. Do I *really* trust these bots to honor it? + +I'm hosting this site [on Neocities](/deploy-hugo-neocities-github-actions/), and Neocities unfortunately (though perhaps wisely) doesn't give me control of the web server there. But the site is fronted by Cloudflare, and that does give me a lot of options for blocking stuff I don't want. + +So I added a [WAF Custom Rule](https://developers.cloudflare.com/waf/custom-rules/) to block those unwanted bots. (I could have used their [User Agent Blocking](https://developers.cloudflare.com/waf/tools/user-agent-blocking) to accomplish the same, but you can only set 10 of those on the free tier. I can put all the user agents together in a single WAF Custom Rule.) + +Here's the expression I'm using: + +```text +(http.user_agent contains "AdsBot-Google") or (http.user_agent contains "Amazonbot") or (http.user_agent contains "anthropic-ai") or (http.user_agent contains "Applebot") or (http.user_agent contains "AwarioRssBot") or (http.user_agent contains "AwarioSmartBot") or (http.user_agent contains "Bytespider") or (http.user_agent contains "CCBot") or (http.user_agent contains "ChatGPT-User") or (http.user_agent contains "ClaudeBot") or (http.user_agent contains "Claude-Web") or (http.user_agent contains "cohere-ai") or (http.user_agent contains "DataForSeoBot") or (http.user_agent contains "FacebookBot") or (http.user_agent contains "Google-Extended") or (http.user_agent contains "GoogleOther") or (http.user_agent contains "GPTBot") or (http.user_agent contains "ImagesiftBot") or (http.user_agent contains "magpie-crawler") or (http.user_agent contains "Meltwater") or (http.user_agent contains "omgili") or (http.user_agent contains "omgilibot") or (http.user_agent contains "peer39_crawler") or (http.user_agent contains "peer39_crawler/1.0") or (http.user_agent contains "PerplexityBot") or (http.user_agent contains "Seekr") or (http.user_agent contains "YouBot") +``` + +![Creating a custom WAF rule in Cloudflare's web UI](cloudflare-waf-rule.png) + +And checking on that rule ~24 hours later, I can see that it's doing some good: + +![It's blocked 102 bot hits already](cloudflare-waf-status.png) + +See ya, AI bots! \ No newline at end of file diff --git a/content/posts/gemini-capsule-gempost-github-actions/deploy-success.png b/content/posts/gemini-capsule-gempost-github-actions/deploy-success.png new file mode 100644 index 0000000..c02b158 Binary files /dev/null and b/content/posts/gemini-capsule-gempost-github-actions/deploy-success.png differ diff --git a/content/posts/gemini-capsule-gempost-github-actions/gemini-capsule.png b/content/posts/gemini-capsule-gempost-github-actions/gemini-capsule.png new file mode 100644 index 0000000..86e4cee Binary files /dev/null and b/content/posts/gemini-capsule-gempost-github-actions/gemini-capsule.png differ diff --git a/content/posts/gemini-capsule-gempost-github-actions/hello-gemini.png b/content/posts/gemini-capsule-gempost-github-actions/hello-gemini.png new file mode 100644 index 0000000..0570f74 Binary files /dev/null and b/content/posts/gemini-capsule-gempost-github-actions/hello-gemini.png differ diff --git a/content/posts/gemini-capsule-gempost-github-actions/http-capsule.png b/content/posts/gemini-capsule-gempost-github-actions/http-capsule.png new file mode 100644 index 0000000..d96963e Binary files /dev/null and b/content/posts/gemini-capsule-gempost-github-actions/http-capsule.png differ diff --git a/content/posts/gemini-capsule-gempost-github-actions/index.md b/content/posts/gemini-capsule-gempost-github-actions/index.md index 58cb638..647cc70 100644 --- a/content/posts/gemini-capsule-gempost-github-actions/index.md +++ b/content/posts/gemini-capsule-gempost-github-actions/index.md @@ -1,9 +1,8 @@ --- -title: "Self-Hosted Gemini Capsule with Gempost and GitHub Actions" -date: 2024-03-15 -# lastmod: 2024-03-15 -draft: true -description: "This is a new post about..." +title: "Self-Hosted Gemini Capsule with gempost and GitHub Actions" +date: "2024-03-23T21:33:19Z" +lastmod: "2024-04-05T21:07:38Z" +description: "Deploying a Gemini capsule, powered by Agate, gempost, kineto, Tailscale, and GitHub Actions" featured: false toc: true comments: true @@ -13,17 +12,18 @@ tags: - cicd - docker - selfhosting + - tailscale --- I've recently been exploring some indieweb/smolweb technologies, and one of the most interesting things I've come across is [Project Gemini](https://geminiprotocol.net/): > Gemini is a new internet technology supporting an electronic library of interconnected text documents. That's not a new idea, but it's not old fashioned either. It's timeless, and deserves tools which treat it as a first class concept, not a vestigial corner case. Gemini isn't about innovation or disruption, it's about providing some respite for those who feel the internet has been disrupted enough already. We're not out to change the world or destroy other technologies. We are out to build a lightweight online space where documents are just documents, in the interests of every reader's privacy, attention and bandwidth. -I thought it was an interesting idea, so after a bit of experimentation with various hosted options I created a self-hosted [Gemini Capsule (Gemini for "web site") to host a lightweight text-focused Gemlog ("weblog")](https://capsule.jbowdre.lol/gemlog/2024-03-05-hello-gemini.gmi). After further tinkering, I arranged to serve the Capsule both on the Gemini network as well as the traditional HTTP-based web, and I set up a GitHub Actions workflow to handle posting updates. This post will describe how I did that. +I thought it was an interesting idea, so after a bit of experimentation with various hosted options I created a self-hosted [Gemini capsule (Gemini for "web site") to host a lightweight text-focused Gemlog ("weblog")](https://capsule.jbowdre.lol/gemlog/2024-03-05-hello-gemini.gmi). After further tinkering, I arranged to serve the capsule both on the Gemini network as well as the traditional HTTP-based web, and I set up a GitHub Actions workflow to handle posting updates. This post will describe how I did that. -### Gemini Server -There are a number of different [Gemini server applications](https://github.com/kr1sp1n/awesome-gemini?tab=readme-ov-file#servers) to choose from. I decided to use [Agate](https://github.com/mbrubeck/agate), not just because it was at the top of the Awesome Gemini list but also because seems to be widely recommended and regularly updated. +### Gemini Server: Agate +There are a number of different [Gemini server applications](https://github.com/kr1sp1n/awesome-gemini?tab=readme-ov-file#servers) to choose from. I decided to use [Agate](https://github.com/mbrubeck/agate), not just because it was at the top of the Awesome Gemini list but also because seems to be widely recommended, regularly updated, and easy to use. Plus it will automatically generates certs for me, which is nice since Gemini *requires* valid certificates for all connections. -I wasn't able to find a pre-built Docker image for Agate (at least not one which had been updated within the past year or so), but I found that I could easily make my own by just installing Agate on top of the standard Rust image. So I came up with this `Dockerfile`: +I wasn't able to find a pre-built Docker image for Agate (at least not one which seemed to be actively maintained), but I found that I could easily make my own by just installing Agate on top of the standard Rust image. So I came up with this `Dockerfile`: ```Dockerfile # torchlight! {"lineNumbers": true} @@ -38,11 +38,10 @@ ENTRYPOINT ["agate"] This very simply uses the [Rust package manager](https://doc.rust-lang.org/cargo/) to install `agate`, change to an appropriate working directory, and start the `agate` executable. -And then I can set up a basic `docker-compose.yaml` for it: +And then I can create a basic `docker-compose.yaml` for it: ```yaml # torchlight! {"lineNumbers": true} -version: "3.9" services: agate: restart: always @@ -53,9 +52,556 @@ services: - ./certs:/var/agate/certs ports: - "1965:1965" - command: --content content --certs certs --addr 0.0.0.0:1965 --hostname capsule.jbowdre.lol --lang en-US + command: > + --content content --certs certs --addr 0.0.0.0:1965 + --hostname capsule.jbowdre.lol --lang en-US ``` +This mounts local directories `./content` and `./certs` into the `/var/agate/` working directory, passes arguments specifying those directories as well as the hostname to the `agate` executable, and exposes the service on port `1965` (commemorating the [first manned Gemini missions](https://en.wikipedia.org/wiki/Project_Gemini#Missions) - I love the space nerd influences here!). +Now I'll throw some quick [Gemtext](https://geminiprotocol.net/docs/gemtext.gmi) into a file in the `./content` directory: +```shell +mkdir -p content # [tl! .cmd] +cat <<< EOF > content/hello-gemini.gmi # [tl! .cmd] +# This is a test of the Gemini broadcast system. +* This is only a test. + +=> gemini://geminiprotocol.net/history/ Gemini History +EOF +``` + +After configuring the `capsule.jbowdre.lol` DNS record and opening port `1965` on my server's firewall, I can use `docker compose up -d` to spawn the server and begin serving my mostly-empty capsule at `gemini://capsule.jbowdre.lol/hello-gemini.gmi`. I can then check it out in a popular Gemini client called [Lagrange](https://github.com/skyjake/lagrange): + +![A sparse sample page proclaiming "This is just a test"](hello-gemini.png) + +Hooray, I have an outpost in Geminispace! Let's dress it up a little bit now. + +### Gemini Content: gempost +A "hello world" post will only hold someone's interest for so long. I wanted to start using my capsule for lightweight blogging tasks, but I didn't want to have to manually keep track of individual posts or manage consistent formatting. After a bit of questing, I found [gempost](https://github.com/justlark/gempost), a static site generator for gemlogs (Gemini blogs). It makes it easy to template out index pages and insert headers/footers, and supports tracking post metadata in a yaml sidecar for each post (handy since gemtext doesn't support front matter or other inline meta properties). + +gempost is installed as a Rust package, so I _could_ have installed Rust and then used `cargo install gempost` to install gempost. But I've been really getting into using project-specific development shells powered by [Nix flakes](https://nixos.wiki/wiki/Flakes) and [direnv](https://github.com/direnv/direnv/wiki/Nix) to automatically load/unload packages as I traverse the directory tree. So I put together [this flake](https://github.com/jbowdre/capsule/blob/main/flake.nix) to activate Agate, Rust, and gempost when I change into the directory where I want to build my capsule content: + +```nix +# torchlight! {"lineNumbers": true} +{ + description = "gemsite build environment"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + pkgs = import nixpkgs { system = "x86_64-linux"; }; + gempost = pkgs.rustPlatform.buildRustPackage rec { + pname = "gempost"; + version = "v0.3.0"; + + src = pkgs.fetchFromGitHub { + owner = "justlark"; + repo = pname; + rev = version; + hash = "sha256-T6CP1blKXik4AzkgNJakrJyZDYoCIU5yaxjDvK3p01U="; + }; + cargoHash = "sha256-jG/G/gmaCGpqXeRQX4IqV/Gv6i/yYUpoTC0f7fMs4pQ="; + }; + in + { + devShells.x86_64-linux.default = pkgs.mkShell { + packages = with pkgs; [ + agate + gempost + ]; + }; + }; +} +``` + +Then I just need to tell direnv to load the flake, by dropping this into `.envrc`: + +```shell +# torchlight! {"lineNumbers":true} +#!/usr/bin/env direnv +use flake . +``` + +Now when I `cd` into the directory where I'm managing the content for my capsule, the appropriate environment gets loaded automagically: + +```shell +which gempost # [tl! .cmd] +cd capsule/ # [tl! .cmd] +direnv: loading ~/projects/capsule/.envrc # [tl! .nocopy:3] +direnv: using flake . +direnv: nix-direnv: using cached dev shell # [tl! collapse:1] +direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BUILD_CORES +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_LDFLAGS +NIX_STORE +NM +OBJCOPY +OBJDUMP +RANLIB +READELF +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +__structuredAttrs +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +dontAddDisableDepTrack +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +preferLocalBuild +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH ~XDG_DATA_DIRS +which gempost # [tl! .cmd] +/nix/store/1jbfsb0gm1pr1ijc8304jqak7iamjybi-gempost-v0.3.0/bin/gempost # [tl! .nodopy] +``` + +Neat! + +I'm not going to go into too much detail on how I configured/use gempost; the [readme](https://github.com/justlark/gempost/blob/main/README.md) does a great job of explaining what's needed to get started, and the [examples directory](https://github.com/justlark/gempost/tree/main/examples) shows some of the flexibility and configurable options. Perhaps the biggest change I made is using `gemlog/` as the gemlog directory (instead of `posts/`). You can check out my [gempost config file](https://github.com/jbowdre/capsule/blob/main/gempost.yaml) and/or [gempost templates](https://github.com/jbowdre/capsule/tree/main/templates) for the full details. + +In any case, after gempost is configured and I've written some posts, I can build the capsule with `gempost build`. It spits out the "rendered" gemtext (basically generating index pages and the Atom feed from the posts and templates) in the `public/` directory. + +If I copy the contents of that `public/` directory into my Agate `content/` directory, I'll be serving this new-and-improved Gemini capsule. But who wants to be manually running `gempost build` and copying files around to publish them? Not this guy. I'll set up a GitHub Actions workflow to take care of that for me. + +But first, let's do a little bit more work to make this capsule available on the traditional HTTP-powered world wide web. + +### Web Proxy: kineto +I looked at a few different options for making Gemini content available on the web. I thought about finding/writing a script to convert geminitext to HTML and just serving that directly. I saw a few alternative Gemini servers which seemed able to cross-host content. But the simplest solution I saw (and one that I've seen used on quite a few other sites) is a Gemini-to-HTTP proxy called [kineto](https://sr.ht/~sircmpwn/kineto/). + +kineto is distributed as a golang package so that's pretty easy to build and install in a Docker image: + +```Dockerfile +# torchlight! {"lineNumbers":true} +FROM golang:1.22 as build +WORKDIR /build +RUN git clone https://git.sr.ht/~sircmpwn/kineto +WORKDIR /build/kineto +RUN go mod download \ + && CGO_ENABLED=0 GOOS=linux go build -o kineto + +FROM alpine:3.19 +WORKDIR /app +COPY --from=build /build/kineto/kineto /app/kineto +ENTRYPOINT ["/app/kineto"] +``` + +I created a slightly-tweaked `style.css` based on kineto's [default styling](https://git.sr.ht/~sircmpwn/kineto/tree/857f8c97ebc5724f4c34931ba497425e7653894e/item/main.go#L257) to tailor the appearance a bit more to my liking. (I've got some more work to do to make it look "good", but I barely know how to spell CSS so I'll get to that later.) + +```css +/* torchlight! {"lineNumbers":true} */ +html { /* [tl! collapse:start] */ + font-family: sans-serif; + color: #080808; +} + +body { + max-width: 920px; + margin: 0 auto; + padding: 1rem 2rem; +} + +blockquote { + background-color: #eee; + border-left: 3px solid #444; + margin: 1rem -1rem 1rem calc(-1rem - 3px); + padding: 1rem; +} + +ul { + margin-left: 0; + padding: 0; +} + +li { + padding: 0; +} + +li:not(:last-child) { + margin-bottom: 0.5rem; +} /* [tl! collapse:end] */ + +a { + position: relative; + text-decoration: dotted underline; /* [tl! ++:start] */ +} + +a:hover { + text-decoration: solid underline; /* [tl! ++:end] */ +} +/* [tl! collapse:start] */ +a:before { + content: '⇒'; + color: #999; + text-decoration: none; + font-weight: bold; + position: absolute; + left: -1.25rem; +} + +pre { + background-color: #eee; + margin: 0 -1rem; + padding: 1rem; + overflow-x: auto; +} + +details:not([open]) summary, +details:not([open]) summary a { + color: gray; +} + +details summary a:before { + display: none; +} + +dl dt { + font-weight: bold; +} + +dl dt:not(:first-child) { + margin-top: 0.5rem; +} /* [tl! collapse:end] */ + +@media(prefers-color-scheme:dark) { /* [tl! collapse:start] */ + html { + background-color: #111; + color: #eee; + } + + blockquote { + background-color: #000; + } + + pre { + background-color: #222; + } + + a { + color: #0087BD; + } /* [tl! collapse:end] */ + + a:visited { + color: #333399; /* [tl! --] */ + color: #006ebd; /* [tl! ++ reindex(-1)] */ + } +} +/* [tl! collapse:start] */ +label { + display: block; + font-weight: bold; + margin-bottom: 0.5rem; +} + +input { + display: block; + border: 1px solid #888; + padding: .375rem; + line-height: 1.25rem; + transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; + width: 100%; +} + +input:focus { + outline: 0; + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); +} /* [tl! collapse:end] */ +``` + +And then I crafted a `docker-compose.yaml` for my kineto instance: + +```yaml +# torchlight! {"lineNumbers":true} +services: + kineto: + restart: always + build: . + container_name: kineto + ports: + - "8081:8080" + volumes: + - ./style.css:/app/style.css + command: -s style.css gemini://capsule.jbowdre.lol +``` + +Port `8080` was already in use for another service on this system, so I had to expose this on `8081` instead. And I used the `-s` argument to `kineto` to load my customized CSS, and then pointed the proxy at the capsule's Gemini address. + +I started this container with `docker compose up -d`... but the job wasn't *quite* done. This server is already using [Caddy webserver](https://caddyserver.com/) for serving some stuff, so I'll use that for fronting kineto as well. That way, the content can be served over the typical HTTPS port and Caddy can manage the certificate for me too. + +So I added this to my `/etc/caddy/Caddyfile`: + +```text +capsule.jbowdre.lol { + reverse_proxy localhost:8081 +} +``` + +And bounced the Caddy service. + +Now my capsule is available at both `gemini://capsule.jbowdre.lol` and `https://capsule.jbowdre.lol`. Agate handles generating the Gemini content, and kineto is able to convert that to web-friendly HTML on the fly. It even handles embedded `gemini://` links pointing to other capsules, allowing legacy web users to explore bits of Geminispace from the comfort of their old-school browsers. + +One thing that kineto _doesn't_ translate, though, are the contents of the atom feed at `public/gemlog/atom.xml`. Whether it's served via Gemini or HTTPS, the contents are static. For instance: + +```xml + + urn:uuid:a751b018-cda5-4c03-bd9d-16bdc1506050 + Hello Gemini + I decided to check out Geminispace. You're looking at my first exploration. + 2024-03-05T17:00:00-06:00 + 2024-03-06T07:45:00-06:00 + + +``` + +No worries, I can throw together a quick script that will generate a web-friendly feed: + +```shell +# torchlight! {"lineNumbers":true} +#!/usr/bin/env bash +# generate-web-feed.sh +set -eu + +INFEED="public/gemlog/atom.xml" +OUTFEED="public/gemlog/atom-web.xml" + +# read INFEED, replace gemini:// with https:// and write to OUTFEED +sed 's/gemini:\/\//https:\/\//g' $INFEED > $OUTFEED +# fix self url +sed -i 's/atom\.xml/atom-web\.xml/g' $OUTFEED +``` + +After running that, I have a `public/gemlog/atom-web.xml` file that I can serve for web visitors, and it correctly points to the `https://` endpoint so that feed readers won't get confused: + +```xml + + urn:uuid:a751b018-cda5-4c03-bd9d-16bdc1506050 + Hello Gemini + I decided to check out Geminispace. You're looking at my first exploration. + 2024-03-05T17:00:00-06:00 + 2024-03-06T07:45:00-06:00 + + +``` + +Let's sort out an automated build process now... + +### Publish: GitHub Actions +To avoid having to manually publish my capsule by running `gempost build` and copying the result into the server's `content/` directory, I'm going to automate that process with a GitHub Actions workflow. It will execute the build process in a GitHub runner and then shift the result to my server. I'll make use of [Tailscale](https://tailscale.com/) to easily establish an rsync-ssh connection between the runner and the server without having to open up any ports publicly. + +I'll start by creating a new GitHub repo for managing my capsule content; let's call it [github.com/jbowdre/capsule](https://github.com/jbowdre/capsule). I'll clone it locally so I can work in it on my laptop and then push changes upstream when I'm ready. So I'll copy/move over all the gempost files I've worked on so far, along with a copy of the Docker configurations for Agate and kineto just so I've got those visible in one place: + +``` +. +├── .certificates +├── .direnv +├── agate +├── gemlog +├── kineto +├── public +├── static +├── templates +├── .envrc +├── .gitignore +├── flake.lock +├── flake.nix +├── gempost.yaml +└── generate-web-feed.sh +``` + +I don't need to sync `.direnv/` (which holds the local direnv state), `public/` (which holds the gempost output - I'll be building that in GitHub shortly), or `.certificates/` (which was automatically created when testing agate locally with `agate --content public --hostname localhost`), so I'll add those to `.gitignore`: + +``` +/.certificates/ +/.direnv/ +/public/ +``` + +Then I can create `.github/workflows/deploy-gemini.yml` to manage the deployment, and I start that by defining when it should be triggered: +- only on pushes to the `main` branch, +- and only when one of the gempost-related files/directories are included in that push. + +It will also set some other sane defaults for the workflow: + +```yaml +# torchlight! {"lineNumbers":true} +name: Deploy Gemini Capsule + +# only run on changes to main +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'gemlog/**' + - 'static/**' + - 'templates/**' + - 'gempost.yaml' + +concurrency: # prevent concurrent deploys doing strange things + group: deploy-gemini-capsule + cancel-in-progress: true + +# Default to bash +defaults: + run: + shell: bash + +``` + +I'll then define my first couple of jobs to: +- use the [cargo-install Action](https://github.com/baptiste0928/cargo-install/tree/v3.0.0/) to install gempost, +- checkout the current copy of my capsule repo, +- run the `gempost build` process, +- and then call my `generate-web-feed.sh` script. + +```yaml +# torchlight! {"lineNumbers":true} +jobs: # [tl! reindex(24)] + deploy: + name: Deploy Gemini Capsule + runs-on: ubuntu-latest + steps: + - name: Install gempost + uses: baptiste0928/cargo-install@v3.0.0 + with: + crate: gempost + - name: Checkout + uses: actions/checkout@v4 + - name: Gempost build + run: gempost build + - name: Generate web feed + run: ./generate-web-feed.sh +``` + +Next, I'll use the [Tailscale Action](https://github.com/tailscale/github-action/tree/v2/) to let the runner connect to my Tailnet. It will use an [OAuth client](https://tailscale.com/kb/1215/oauth-clients) to authenticate the new node, and will apply an [ACL tag](https://tailscale.com/kb/1068/acl-tags) which grants access _only_ to my web server. + +```yaml +# torchlight! {"lineNumbers":true} + - name: Connect to Tailscale # [tl! reindex(39)] + uses: tailscale/github-action@v2 + with: + oauth-client-id: ${{ secrets.TS_API_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_API_CLIENT_SECRET }} + tags: ${{ secrets.TS_TAG }} +``` + +I'll let [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) facilitate the authentication for the rsync-ssh login, but I need to pre-stage `~/.ssh/known_hosts` on the runner so that the client will know it can trust the server: + +```yaml +# torchlight! {"lineNumbers":true} + - name: Configure SSH known hosts # [tl! reindex(45)] + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts +``` + +And finally, I'll use `rsync` to transfer the contents of the gempost-produced `public/` folder to the Agate `content/` folder on the remote server: + +```yaml +# torchlight! {"lineNumbers":true} + - name: Deploy GMI to Agate # [tl! reindex(50)] + run: | + rsync -avz --delete -e ssh public/ deploy@${{ secrets.GMI_HOST }}:${{ secrets.GMI_CONTENT_PATH }} +``` + +You can view the workflow in its entirety on [GitHub](https://github.com/jbowdre/capsule/blob/main/.github/workflows/deploy-gemini.yml). But before I can actually use it, I'll need to configure Tailscale, set up the server, and safely stash those `secrets.*` values in the repo. + +#### Tailscale configuration +I want to use a pair of ACL tags to identify the GitHub runner and the server, and ensure that the runner can connect to the server over port 22. To create a new tag in a Tailscale ACL, I need to assign the permission, so I'll add this to my ACL file: + +```json + "tagOwners": { + "tag:gh-bld": ["group:admins"], // github builder + "tag:gh-srv": ["group:admins"], // server it can deploy to + }, +``` + +Then I'll add this to the `acls` block: + +```json + "acls": [ + { + // github runner can talk to the deployment target + "action": "accept", + "users": ["tag:gh-bld"], + "ports": [ + "tag:gh-srv:22" + ], + } + ], +``` + +And I'll use this to configure [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) to define that the runner can log in to the web server as the `deploy` user using a keypair automagically managed by Tailscale: + +```json + "ssh": [ + { + "action": "accept", + "src": ["tag:gh-bld"], + "dst": ["tag:gh-srv"], + "users": ["deploy"], + } + ], +``` + +To generate the OAuth client, I'll head to [Tailscale Admin Console > Settings > OAuth Clients](https://login.tailscale.com/admin/settings/oauth) and click the **Generate OAuth Client** button. The new client only needs to have the ability to read and write new devices, and I set it to assign the `tag:gh-bld` tag: + +![OAuth Client generation screen showing Read and Write privileges enabled for the Devices scope and the `tag:gh-bld` tag being applied.](oauth-client-generation.png) + +I take the Client ID and Client Secret store those in the GitHub Repo as Actions Repository Secrets named `TS_API_CLIENT_ID` and `TS_API_CLIENT_SECRET`. I also save the tag as `TS_TAG`. + +#### Server configuration +From my previous manual experimentation, I have the Docker configuration for the Agate server hanging out at `/opt/gemini/`, and kineto is on the same server at `/opt/kineto/` (though it doesn't really matter where kineto actually lives - it can proxy any `gemini://` address from anywhere). + +I'm actually using [Agate's support for Virtual Hosts](https://github.com/mbrubeck/agate?tab=readme-ov-file#virtual-hosts) to serve both the capsule I've been discussing in this post as well as a version of _this very site_ available in Geminispace at `gemini://gmi.runtimeterror.dev`. By passing multiple hostnames to the `agate` command line, it looks for hostname-specific subdirs inside of the `content` and `certs` folders. So my production setup looks like this: + +```text +. +├── certs +│ ├── capsule.jbowdre.lol +│ └── gmi.runtimeterror.dev +├── content +│ ├── capsule.jbowdre.lol +│ └── gmi.runtimeterror.dev +├── docker-compose.yaml +└── Dockerfile +``` + +And the `command:` line of `docker-compose.yaml` looks like this: + +```yaml + command: > + --content content --certs certs --addr 0.0.0.0:1965 + --hostname gmi.runtimeterror.dev + --hostname capsule.jbowdre.lol + --lang en-US +``` + +I told the Tailscale ACL that the runner should be able to log in to the server as the `deploy` user, so I guess I'd better create that account on the server: + +```shell +sudo useradd -U -m -s /usr/bin/bash deploy # [tl! .cmd] +``` + +And I want to make that user the owner of the content directories so that it can use `rsync` to overwrite them when changes are made: + +```shell +sudo chown -R deploy:deploy /opt/gemini/content/capsule.jbowdre.lol # [tl! .cmd:1] +sudo chown -R deploy:deploy /opt/gemini/content/gmi.runtimeterror.dev +``` + +I should also make sure that the server bears the appropriate Tailscale tag and that it has Tailscale SSH enabled: + +```shell +sudo tailscale up --advertise-tags="tag:gh-srv" --ssh +``` + +I can use the `ssh-keyscan` utility _from another system_ to retrieve the server's SSH public keys so they can be added to the runner's `known_hosts` file: + +```shell +ssh-keyscan -H $servername # [tl! .cmd] +``` + +I'll copy the entirety of that output and save it as a repository secret named `SSH_KNOWN_HOSTS`. I'll also save the Tailnet node name as `GMI_HOST` and the full path to the capsule's content directory (`/opt/gemini/content/capsule.jbowdre.lol/`) as `GMI_CONTENT_PATH`. + +### Deploy! +All the pieces are in place, and all that's left is to `git commit` and `git push` the repo I've been working on. With a bit of luck (and a lot of ~~failures~~ _lessons learned_), GitHub shows a functioning deployment! + +![GitHub Actions reporting a successful workflow execution](deploy-success.png) + +And the capsule is live at both `https://capsule.jbowdre.lol` and `gemini://capsule.jbowdre.lol`: + +![Gemini capsule served over https://](http-capsule.png) + +![Gemini capsule served over gemini://](gemini-capsule.png) + +Come check it out! +- [My Capsule on Gemini](gemini://capsule.jbowdre.lol) +- [My Capsule on the web](https://capsule.jbowdre.lol) \ No newline at end of file diff --git a/content/posts/gemini-capsule-gempost-github-actions/oauth-client-generation.png b/content/posts/gemini-capsule-gempost-github-actions/oauth-client-generation.png new file mode 100644 index 0000000..f8ccc9b Binary files /dev/null and b/content/posts/gemini-capsule-gempost-github-actions/oauth-client-generation.png differ diff --git a/content/posts/publish-services-cloudflare-tunnel/index.md b/content/posts/publish-services-cloudflare-tunnel/index.md index ec9c8c7..c281774 100644 --- a/content/posts/publish-services-cloudflare-tunnel/index.md +++ b/content/posts/publish-services-cloudflare-tunnel/index.md @@ -8,6 +8,7 @@ toc: true comments: true categories: Self-Hosting tags: + - cloudflare - cloud - containers - docker diff --git a/layouts/partials/aside.html b/layouts/partials/aside.html index 3fb8231..934ef3b 100644 --- a/layouts/partials/aside.html +++ b/layouts/partials/aside.html @@ -42,7 +42,10 @@

status.lol

+
+ {{- if eq .Site.Params.analytics true }}
+

webring

️🕸💍 {{- end }} \ No newline at end of file diff --git a/layouts/partials/opengraph.html b/layouts/partials/opengraph.html index 7bddaa1..9f7f463 100644 --- a/layouts/partials/opengraph.html +++ b/layouts/partials/opengraph.html @@ -1,6 +1,16 @@ {{ $img := resources.Get "og_base.png" }} -{{ $font := resources.Get "/FiraMono-Regular.ttf" }} {{ $text := "" }} +{{ $font := "" }} +{{ $path := os.Getenv "HUGO_REMOTE_FONT_PATH" }} +{{ with resources.GetRemote $path }} + {{ with .Err }} + {{ errorf "%s" . }} + {{ else }} + {{ $font = . }} + {{ end }} +{{ else }} + {{ errorf "Unable to get resource %q" $path }} +{{ end }} diff --git a/layouts/robots.txt b/layouts/robots.txt new file mode 100644 index 0000000..693c133 --- /dev/null +++ b/layouts/robots.txt @@ -0,0 +1,8 @@ +Sitemap: {{ .Site.BaseURL }}/sitemap.xml + +User-agent: * +Disallow: +{{ range .Site.Params.robots }} +User-agent: {{ . }} +{{- end }} +Disallow: / diff --git a/static/ai.txt b/static/ai.txt new file mode 100644 index 0000000..0bdaa51 --- /dev/null +++ b/static/ai.txt @@ -0,0 +1,6 @@ +# Spawning AI +# Prevent datasets from using the following file types + +User-Agent: * +Disallow: / +Disallow: * \ No newline at end of file diff --git a/static/css/custom.css b/static/css/custom.css index 706c21f..d4208f8 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,6 +1,37 @@ /* color overrides */ :root { --code: var(--base06); + --font-monospace: 'Berkeley Mono', 'IBM Plex Mono', 'Cascadia Mono', 'Roboto Mono', 'Source Code Pro', 'Fira Mono', 'Courier New', 'monospace'; +} + +@font-face { + font-family: 'Berkeley Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: local('Berkeley Mono'), + url('https://cdn.runtimeterror.dev/fonts/BerkeleyMono-Regular.woff2') format('woff2'), + url('https://cdn.runtimeterror.dev/fonts/BerkeleyMono-Regular.woff') format('woff') +} + +@font-face { + font-family: 'Berkeley Mono'; + font-style: italic; + font-weight: 400; + font-display: fallback; + src: local('Berkeley Mono Italic'), + url('https://cdn.runtimeterror.dev/fonts/BerkeleyMono-Italic.woff2') format('woff2'), + url('https://cdn.runtimeterror.dev/fonts/BerkeleyMono-Italic.woff') format('woff') +} + +@font-face { + font-family: 'Berkeley Mono'; + font-style: normal; + font-weight: 600; + font-display: fallback; + src: local('Berkeley Mono Bold'), + url('https://cdn.runtimeterror.dev/fonts/BerkeleyMono-Bold.woff2') format('woff2'), + url('https://cdn.runtimeterror.dev/fonts/BerkeleyMono-Bold.woff') format('woff') } /* override page max-width */ @@ -315,6 +346,20 @@ a.tinylytics_webring { font-size: 1.5rem; } +/* shoutouts styling */ +.shoutout .shoutout__container .shoutout__title { + font-size: 1rem; + margin: 0; + margin-bottom: 0.5rem; + font-weight: bold; +} + +.shoutout .shoutout__container .shoutout__content { + font-style: italic; + font-size: 0.9rem; + line-height: 1.2rem; +} + /* post front matter styling*/ .frontmatter hr { margin-bottom: 0rem;