Compare commits

...

36 commits

Author SHA1 Message Date
190e28d895
update draft 2024-09-08 17:58:39 -05:00
045ccc8a6b
update draft 2024-09-07 17:39:09 -05:00
b1b27ced45 new draft: installing-tailscale-robot-vacuum 2024-08-29 20:56:36 -05:00
066c873ca1 Merge branch 'main' into drafts 2024-08-29 20:30:09 -05:00
08c5efb655 new post: silverbullet-self-hosted-knowledge-management 2024-08-21 21:57:23 -05:00
e6180a1815 new post: silverbullet-self-hosted-knowledge-management 2024-08-21 21:56:51 -05:00
bae5c63a70 Merge branch 'main' into drafts 2024-08-21 20:46:03 -05:00
8543c9d670 adjust opacity/color of anchor links 2024-08-21 08:33:26 -05:00
20b7963ee1 add anchor links on section headings 2024-08-20 22:12:30 -05:00
2f33595326 update draft 2024-08-20 21:53:50 -05:00
fb20c34ebe update draft 2024-08-17 21:32:11 -05:00
5e428b41fa Merge branch 'main' into drafts 2024-08-17 20:17:15 -05:00
222f1276c5 set initial theme 2024-08-16 17:44:46 -05:00
f0b675cddc theme toggle tweaks
- use standard sun/moon icons
- reduce button size by .5rem
2024-08-16 16:18:35 -05:00
99067ebc8b Merge branch 'main' into drafts 2024-08-16 14:43:44 -05:00
00d71d21e9 improve themability of notice blocks 2024-08-15 21:09:22 -05:00
ae966098ee fix for missing icon on old post notes 2024-08-15 21:08:59 -05:00
1301721d6f changelog: theme toggle 2024-08-15 16:21:19 -05:00
John Bowdre
d7e19b0a5c
Merge pull request #8 from jbowdre/theme_selection
Theme selection
2024-08-15 16:17:00 -05:00
2de36c1b02 implement dark/light toggle 2024-08-15 15:56:47 -05:00
3ced86d8d2 remove trailing space 2024-08-15 15:56:17 -05:00
3e90fb47a1 use separate color var for logo 2024-08-15 15:50:26 -05:00
baa8f624e4 use vars for colors in torchlight styling 2024-08-15 15:49:17 -05:00
b23983c02c change torchlight theme
this switches to a more neutral theme which looks okay against both
light and dark backgrounds
2024-08-15 15:43:39 -05:00
64ad1416d3 create light color palette 2024-08-15 15:36:46 -05:00
b9a8cdc849 update font-awesome 2024-08-15 15:36:13 -05:00
a0e47f7b49 errors++; 2024-08-14 14:24:44 -05:00
66fd3d8199 Merge branch 'main' into drafts 2024-08-13 20:45:28 -05:00
6425bd6959 cycle featured posts 2024-08-13 10:37:40 -05:00
602303ce15 homelab: add silverbullet 2024-08-12 21:18:08 -05:00
18828fcc33 new draft: silverbullet-self-hosted-knowledge-management 2024-08-12 21:17:23 -05:00
4559ad8b2c Merge branch 'main' into drafts 2024-08-12 20:44:43 -05:00
fddb82ee13 new post: dynamic-robots-txt-hugo-external-data-sources 2024-08-06 17:00:20 -05:00
0ad5838e9c use placeholder in range expression 2024-08-05 21:02:13 -05:00
417fb5f2b2 Merge branch 'main' into drafts 2024-08-05 21:01:28 -05:00
061cb570de use 'git' as placeholder in range expression 2024-08-05 21:01:14 -05:00
44 changed files with 1099 additions and 119 deletions

View file

@ -126,17 +126,17 @@ opacity: 1;
Insert prompt indicators on interactive shells.
*/
.cmd::before {
color: var(--base07);
color: var(--user-prompt);
content: "$ ";
}
.cmd_root::before {
color: var(--base08);
color: var(--root-prompt);
content: "# ";
}
.cmd_pwsh::before {
color: var(--base07);
color: var(--user-prompt);
content: "PS> ";
}

42
assets/js/set-theme.js Normal file
View file

@ -0,0 +1,42 @@
const toggleButton = document.getElementById('themeToggle');
const htmlElement = document.documentElement;
function setTheme(theme) {
htmlElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
updateToggleButton(theme);
}
function updateToggleButton(theme) {
toggleButton.setAttribute('aria-checked', theme === 'dark');
}
function getPreferredTheme() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Ensure the toggle button state is correct on load
document.addEventListener('DOMContentLoaded', () => {
updateToggleButton(getPreferredTheme());
});
// Listen for toggle button clicks
toggleButton.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-theme') || getPreferredTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
const newTheme = e.matches ? 'dark' : 'light';
setTheme(newTheme);
}
});
setTheme(getPreferredTheme());

View file

@ -110,6 +110,7 @@ taglines = [
"there's no place like $HOME",
"time jumped backwards, rotating",
"tonight we test in prod",
"unable to decrypt error",
"unable to open display",
"undefined reference to function",
"unexpected token",

View file

@ -1,7 +1,7 @@
---
title: "/changelog"
date: "2024-05-26T21:19:08Z"
lastmod: "2024-08-04T22:30:43Z"
lastmod: "2024-08-21T03:11:27Z"
description: "Maybe I should keep a log of all my site-related tinkering?"
featured: false
toc: false
@ -10,6 +10,12 @@ categories: slashes
---
*Running list of config/layout changes to the site. The full changelog is of course [on GitHub](https://github.com/jbowdre/runtimeterror/commits/main/).*
**2024-08-20:**
- Added anchor links on section headings
**2024-08-15:**
- Implemented light/dark theme toggle
**2024-08-04:**
- Dynamically build `robots.txt` based on [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt)

View file

@ -1,7 +1,7 @@
---
title: "/homelab"
date: "2024-05-26T21:30:51Z"
lastmod: "2024-06-14T01:44:01Z"
lastmod: "2024-08-13T02:12:54Z"
aliases:
- playground
description: "The systems I use for fun and enrichment."
@ -37,6 +37,7 @@ The Proxmox cluster hosts a number of VMs and LXC containers:
- [Hashicorp Vault](https://www.vaultproject.io/) for secrets management
- [Miniflux](https://miniflux.app/) feed reader
- [RIPE Atlas Probe](https://www.ripe.net/analyse/internet-measurements/ripe-atlas/) for measuring internet connectivity
- [SilverBullet](https://silverbullet.md), a web-based personal knowledge management system
- [Tailscale Golink](https://github.com/tailscale/golink), a private shortlink service ([post](/tailscale-golink-private-shortlinks-tailnet/))
- `files`: Ubuntu 20.04 file server. Serves (selected) files semi-publicly through [Tailscale Funnel](/tailscale-ssh-serve-funnel/#tailscale-funnel)
- `hassos`: [Home Assistant OS](https://www.home-assistant.io/installation/), manages all my "smart home" stuff ([post](/automating-camera-notifications-home-assistant-ntfy/))

View file

@ -4,6 +4,7 @@ date: "2018-09-26T08:34:30Z"
lastmod: "2022-03-06"
thumbnail: i0UKdXleC.png
usePageBundles: true
featured: true
tags:
- docker
- linux

View file

@ -0,0 +1,190 @@
---
title: "Generate a Dynamic robots.txt File in Hugo with External Data Sources"
date: "2024-08-06T16:59:39Z"
# lastmod: 2024-08-05
description: "Using Hugo resources.GetRemote to fetch a list of bad bots and generate a valid robots.txt at build time."
featured: false
toc: true
reply: true
categories: Backstage
tags:
- api
- hugo
- meta
---
I shared [back in April](/blocking-ai-crawlers/) my approach for generating a `robots.txt` file to <s>block</s> *discourage* AI crawlers from stealing my content. That setup used a static list of known-naughty user agents (derived from the [community-maintained `ai.robots.txt` project](https://github.com/ai-robots-txt/ai.robots.txt)) in my Hugo config file. It's fine (I guess) but it can be hard to keep up with the bad actors - and I'm too lazy to manually update my local copy of the list when things change.
Wouldn't it be great if I could cut out the middle man (me) and have Hugo work straight off of that remote resource? Inspired by [Luke Harris's work](https://www.lkhrs.com/blog/2024/darkvisitors-hugo/) with using Hugo's [`resources.GetRemote` function](https://gohugo.io/functions/resources/getremote/) to build a `robots.txt` from the [Dark Visitors](https://darkvisitors.com/) API, I set out to figure out how to do that for ai.robots.txt.
While I was tinkering with that, [Adam](https://adam.omg.lol/) and [Cory](https://coryd.dev/) were tinkering with a GitHub Actions workflow to streamline the addition of new agents. That repo now uses [a JSON file](https://github.com/ai-robots-txt/ai.robots.txt/blob/main/robots.json) as the Source of Truth for its agent list, and a JSON file available over HTTP looks an *awful* lot like a poor man's API to me.
So here's my updated solution.
As before, I'm taking advantage of Hugo's [robot.txt templating](https://gohugo.io/templates/robots/) to build the file. That requires the following option in my `config/hugo.toml` file:
```toml
enableRobotsTXT = true
```
That tells Hugo to process `layouts/robots.txt`, which I have set up with this content to insert the sitemap and greet robots who aren't assholes:
```text
# torchlight! {"lineNumbers":true}
Sitemap: {{ .Site.BaseURL }}sitemap.xml
# hello robots [^_^]
# let's be friends <3
User-agent: *
Disallow:
# except for these bots which are not friends:
{{ partial "bad-robots.html" . }}
```
I opted to break the heavy lifting out into `layouts/partials/bad-robots.html` to keep things a bit tidier in the main template. This starts out simply enough with using `resources.GetRemote` to fetch the desired JSON file, and printing an error if that doesn't work:
```jinja
# torchlight! {"lineNumbers":true}
{{- $url := "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json" -}}
{{- with resources.GetRemote $url -}}
{{- with .Err -}}
{{- errorf "%s" . -}}
{{- else -}}
```
The JSON file looks a bit like this, with the user agent strings as the top-level keys:
```json
{
"Amazonbot": { // [tl! **]
"operator": "Amazon",
"respect": "Yes",
"function": "Service improvement and enabling answers for Alexa users.",
"frequency": "No information. provided.",
"description": "Includes references to crawled website when surfacing answers via Alexa; does not clearly outline other uses."
},
"anthropic-ai": { // [tl! **]
"operator": "[Anthropic](https:\/\/www.anthropic.com)",
"respect": "Unclear at this time.",
"function": "Scrapes data to train Anthropic's AI products.",
"frequency": "No information. provided.",
"description": "Scrapes data to train LLMs and AI products offered by Anthropic."
},
"Applebot-Extended": { // [tl! **]
"operator": "[Apple](https:\/\/support.apple.com\/en-us\/119829#datausage)",
"respect": "Yes",
"function": "Powers features in Siri, Spotlight, Safari, Apple Intelligence, and others.",
"frequency": "Unclear at this time.",
"description": "Apple has a secondary user agent, Applebot-Extended ... [that is] used to train Apple's foundation models powering generative AI features across Apple products, including Apple Intelligence, Services, and Developer Tools."
},
{...}
}
```
There's quite a bit more detail in this JSON than I really care about; all I need for this are the bot names. So I unmarshal the JSON data, iterate through the top-level keys to extract the names, and print a line starting with `User-agent: ` followed by the name for each bot.
```jinja
# torchlight! {"lineNumbers":true, "lineNumbersStart":6}
{{- $robots := unmarshal .Content -}}
{{- range $botname, $_ := $robots }}
{{- printf "User-agent: %s\n" $botname }}
{{- end }}
```
And once the loop is finished, I print the important `Disallow: /` rule (and a plug for the repo) and clean up:
```jinja
# torchlight! {"lineNumbers":true, "lineNumbersStart":10}
{{- printf "Disallow: /\n" }}
{{- printf "\n# (bad bots bundled by https://github.com/ai-robots-txt/ai.robots.txt)" }}
{{- end -}}
{{- else -}}
{{- errorf "Unable to get remote resource %q" $url -}}
{{- end -}}
```
So here's the completed `layouts/partials/bad-robots.html`:
```jinja
# torchlight! {"lineNumbers":true}
{{- $url := "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json" -}}
{{- with resources.GetRemote $url -}}
{{- with .Err -}}
{{- errorf "%s" . -}}
{{- else -}}
{{- $robots := unmarshal .Content -}}
{{- range $botname, $_ := $robots }}
{{- printf "User-agent: %s\n" $botname }}
{{- end }}
{{- printf "Disallow: /\n" }}
{{- printf "\n# (bad bots bundled by https://github.com/ai-robots-txt/ai.robots.txt)" }}
{{- end -}}
{{- else -}}
{{- errorf "Unable to get remote resource %q" $url -}}
{{- end -}}
```
After that's in place, I can fire off a quick `hugo server` in the shell and check out my work at `http://localhost:1313/robots.txt`:
```text
Sitemap: http://localhost:1313/sitemap.xml
# hello robots [^_^]
# let's be friends <3
User-agent: *
Disallow:
# except for these bots which are not friends:
User-agent: Amazonbot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: CCBot
User-agent: ChatGPT-User
User-agent: Claude-Web
User-agent: ClaudeBot
User-agent: Diffbot
User-agent: FacebookBot
User-agent: FriendlyCrawler
User-agent: GPTBot
User-agent: Google-Extended
User-agent: GoogleOther
User-agent: GoogleOther-Image
User-agent: GoogleOther-Video
User-agent: ICC-Crawler
User-agent: ImageSift
User-agent: Meta-ExternalAgent
User-agent: OAI-SearchBot
User-agent: PerplexityBot
User-agent: PetalBot
User-agent: Scrapy
User-agent: Timpibot
User-agent: VelenPublicWebCrawler
User-agent: YouBot
User-agent: anthropic-ai
User-agent: cohere-ai
User-agent: facebookexternalhit
User-agent: img2dataset
User-agent: omgili
User-agent: omgilibot
Disallow: /
# (bad bots bundled by https://github.com/ai-robots-txt/ai.robots.txt)
```
Neat!
### Next Steps
Of course, bad agents being disallowed in a `robots.txt` doesn't really accomplish anything if they're [just going to ignore that and scrape my content anyway](https://rknight.me/blog/perplexity-ai-robotstxt-and-other-questions/). I closed my [last post](/blocking-ai-crawlers/) on the subject with a bit about the Cloudflare WAF rule I had created to actively block these known bad actors. Since then, two things have changed:
First, Cloudflare rolled out an even easier way to [block bad bots with a single click](https://blog.cloudflare.com/declaring-your-aindependence-block-ai-bots-scrapers-and-crawlers-with-a-single-click). If you're using Cloudflare, just enable that and call it day.
Second, this site is [now hosted (and fronted) by Bunny](/further-down-the-bunny-hole/) so the Cloudflare solutions won't help me anymore.
Instead, I've been using [Melanie's handy script](https://paste.melanie.lol/bunny-ai-blocking.js) to create a Bunny edge rule (similar to the Cloudflare WAF rule) to handle the blocking.
Going forward, I think I'd like to explore using [Bunny's new Terraform provider](https://registry.terraform.io/providers/BunnyWay/bunnynet/latest/docs) to manage the [edge rule](https://registry.terraform.io/providers/BunnyWay/bunnynet/latest/docs/resources/pullzone_edgerule) in a more stateful way. But that's a topic for another post!

View file

@ -4,7 +4,7 @@ date: 2023-08-26
lastmod: 2023-08-31
timeless: true
description: There are no dumb questions - but there are smarter (and dumber) ways to ask them.
featured: true
featured: false
aliases: ["how2ask"]
categories: Tips
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View file

@ -0,0 +1,368 @@
---
title: "Installing Tailscale on a Robot Vacuum"
date: 2024-08-29
# lastmod: 2024-08-29
draft: true
description: "This is a new post about..."
featured: false
toc: true
reply: true
tags:
- api
- automation
- homeassistant
- linux
- tailscale
---
Continuing my mission to *[Tailscale](/tags/tailscale/) all the things!*, I recently added my robot vacuum (a [Roborock S5 Max](https://us.roborock.com/pages/roborock-s5-max)) to my tailnet.
Okay, installing Tailscale wasn't the *only* thing I did. I also loaded it with software to control it locally so that it no longer has to talk to The Cloud. (And then I installed Tailscale because I can.)
I was inspired to give this a go after reading the [Tailscale sucks](https://tailscale.dev/blog/tailscale-sucks) post on the Tailscale developer blog. That post educated me about a couple of very helpful projects:
- [Valetudo](https://valetudo.cloud/), a web-based front-end for robot vacuums by Sören Beye
- [DustBuilder](https://builder.dontvacuum.me/), a custom firmware builder for a variety of robot vacuums by Dennis Giese
Both sites also include a wealth of documentation to guide you along your robot-hacking journey, including a [list of supported robots](https://valetudo.cloud/pages/general/supported-robots.html) and a [detailed technical/feature comparison of a variety of robots[(https://robotinfo.dev/).
This post will detail all the steps I took to hack my robot, install Tailscale, configure Valetudo, and get it connected to Home Assistant. (I also semi-live-blogged the process on [social.lol](https://social.lol/@jbowdre/112911333551545789)).
### Getting started
I started by looking at the Valetudo [supported robots list](https://valetudo.cloud/pages/general/supported-robots.html) to see that `Rooting [my S5 Max] requires full disassembly.` Clicking through to the [FEL rooting instructions](https://valetudo.cloud/pages/installation/roborock.html#fel) further warned that `This rooting method is not suited for beginners.`.
> Don't threaten me with a good time.
Before getting into the robots guts, though, I went ahead and requested the appropriate firmware so that I wouldn't have to wait on it later.
### Firmware generation
I went to the [Dustbuilder](https://builder.dontvacuum.me/) page, scrolled until I found the entry for the S5 Max (which also goes by `s5e` in some circles), and clicked to access [that builder](https://builder.dontvacuum.me/_s5e.html).
![Dustbuilder interface showing that I've selected to include an RSA public key for SSH and (importantly) chosen the option to "Create FEL image".](dustbuilder.png)
I generated a new RSA keypair to use for the initial SSH connection:
```shell
ssh-keygen -t rsa -f ~/.ssh/id_rsa-robot # [tl! .cmd]
```
And I uploaded the resulting `id_rsa-robot.pub` to the Dustbuilder interface.
I also made sure to select the `Create FEL image (for initial rooting via USB)` option. Otherwise I left the other options alone, entered a Cloaked email address, and clicked the **Create job** button to kick things off.
### Robot surgery
While my firmware was in the oven I went ahead and began the disassembly process. I started by watching [a disassembly video](https://www.youtube.com/watch?v=68flJFSOK8A) but quickly realized that my S5 Max apparently differs from the one being taken apart: after carefully prying off the top cover I found that my robot doesn't have a set of screws surrounding the spinning laser assembly. The inner top cover is a single, solid piece of plastic rather than having a separate cover for the laser.
![The top cover of the robot is a single piece of plastic, without a separate removable piece over the laser sensor.](robot_top.jpg)
So I ditched the video and forged my own path:
1. Remove the dustbin and water basin.
2. Turn robot upside-down.
3. Remove main roller assembly.
4. Unscrew side brush.
![The single screw holding the side brush is circled in red.](side_brush.jpg)
5. Unscrew 6 large screws and remove the bottom cover, exposing the battery.
![Six large screws circled in red.](6x_bottom_screws.jpg)
6. Unscrew 8 small screws securing the two-piece bumper to the edge of the robot and remove the bumper.
![Eight small screws hold the bumper assembly.](8x_bumper_screws.jpg)
7. Disconnect and remove battery.
8. Unscrew 3 small screws holding the side brush motor and remove the motor (my pictures got out of order so I don't actually show the motor removal until much later... please just pretend that I removed it like I said I did).
![Three screws hold the side motor assembly.](3x_side_motor_screws.jpg)
9. There are several groups of screws to extract so you can separate the top and bottom halves of the robot:
1. 2 screws in the battery compartment.
![Two screws in the bottom of the battery compartment.](2x_battery_screws.jpg)
2. 4 short screws near the rear of the robot, where the water basin attaches.
![Four short screws near the rear of the robot.](4x_short_screws.jpg)
3. 1 screw next to one of the main wheel motors.
4. 1 recessed screw next to the other motor.
![Two screws next to the wheel motors.](2x_motor_screws.jpg)
5. And 8 recessed screws along the robot edges, including one which was covered by the side brush motor.
![Eight recessed screws along the edges of the robot.](8x_recessed_screws.jpg)
![A screw which was covered by the side brush assembly.](hidden_screw.jpg)
10. Now lift the entire bottom shell to separate it from the top, and flip it over to expose the electronic goodies.
![Exposed robot mainboard and other electronics.](top_cover_removed.jpg)
11. Carefully remove the 8 connectors along the front edges of the mainboard; the ones along the rear can stay connected.
![Eight electrical connectors along the edges of the mainboard.](8x_mainboard_connectors.jpg)
12. Remove 6 screws from the mainboard.
![Six screws securing the mainboard.](6x_mainboard_screws.jpg)
13. Carefully lift the mainboard into a vertical position.
![The mainboard lifted perpendicular to the robot body.](mainboard_up.jpg)
That's it! Just 40 screws to be able to access the bottom side of the mainboard, and I was finally ready to launch the attack.
### Launching the hack
I checked my email and found that I had indeed received notification of a successful Dustbuilder build so I downloaded those files:
```shell
ls -l robo* # [tl! .cmd .nocopy:1,2]
.rw-r--r-- 5.3M john 9 Aug 19:02 roborock.vacuum.s5e_1668_fel.zip
.rw-r--r-- 28M john 9 Aug 19:00 roborock.vacuum.s5e_1668_fw.tar.gz
```
I also grabbed the [latest `valetudo-armv7-lowmem.upx` binary](https://github.com/Hypfer/Valetudo/releases/latest/download/valetudo-armv7-lowmem.upx) from the [Valetudo GitHub](https://github.com/Hypfer/Valetudo/).
And I installed the `sunxi-tools` package needed for communicating with the robot over USB:
```shell
sudo apt update # [tl! .cmd:1]
sudo apt install sunxi-tools
```
I used a micro-USB cable to connect the mainboard's debugging port to my laptop.
![The robot's micro-USB port on the top side of the mainboard.](mainboard_usb.jpg)
![A disassembled robotic vacuum cleaner lying on a desk. There is a laptop computer also on the desk, with code visible on the screen. Several cables and a screwdriver are also visible on the desk. The screen shows code related to the robot vacuum.](laptop_connected.jpg)
And I then connected the robot's battery to the mainboard (but without powering on the robot).
Next came the only truly tricky part of this whole process: shorting the `TPA17` test point on the bottom side of the mainboard to ground, while *also* pressing and holding the power button on the *top* side of the board (it's the button labeled `KEYI3`).
![TPA17 on the bottom of the robot mainboard.](mainboard_tpa17.jpg)
![The robot's power button labeled "KEYI3".](mainboard_power.jpg)
I used my fingernail to gently scrape the coating off the pad to make good contact, and settled on using a bent paperclip for the shunt. I was able to hook one end of the clip through one of the (grounded) screw holes and then only have to worry about precisely placing the other end on `TPA17`, which freed up my other hand for mashing the button.
The technique is to short `TPA17`, press-and-hold the power button for three seconds, and keep `TPA17` shorted to ground for five more seconds after that.
I did that, the status LEDs on the robot came alive, and I went to my terminal to check the status with `lsusb` to see if the `Allwinner Technology sunxi SoC OTG connector in FEL/flashing mode` showed up.
> For a moment, nothing happened.
> Then, after a second or so, nothing continued to happen.
>
> - Douglas Adams
"Okay," I thought to myself, "It must be ChromeOS getting in the way." So I tried my backup laptop (which, admittedly, started as a Chromebook but now runs NixOS). Interestingly, the same thing there - no Allwinner Chicken Dinner.
After half an hour of repeating the same steps and hoping for different results, I had the bright idea to try a different micro-USB cable.
Unfortunately, that didn't change things. So I kept repeating the same steps some more and hoping for different results. Eventually, I tried *another* micro-USB cable, and that seemed to do the trick! ChromeOS prompted me to connect the device to the Linux environment, and `lsusb` then returned the desired result:
```shell
lsusb # [tl! .cmd]
Bus 001 Device 014: ID 1f3a:efe8 Allwinner Technology sunxi SoC OTG connector in FEL/flashing mode # [tl! .nocopy ~~]
```
{{% notice note "\"Universal\" Serial Bus" %}}
I don't have many micro-USB cables left in my house at this point, and most of the ones I do still have arrived as charging cables for various cheap devices. As it turns out, those two other cables I tried first were charging-only cables without any wires to carry the USB data signals.
I had to go digging through my cable drawer to find a quality braided cable (that I knew supported data transfer). If I had just started with a decent cable from the start I could have saved a lot of time and trouble.
{{% /notice %}}
Once I knew that my Chromebook would actually be able to communicate with the robot, I extracted the contents of the `roborock.vacuum.s5e_1668_fel.zip` archive:
```shell
unzip -d fel roborock.vacuum.s5e_1668_fel.zip # [tl! .cmd:2]
cd fel
ls -l
.rw-r--r-- 184 john 9 Aug 19:02 activation.lic # [tl! .nocopy:10]
.rwxr-xr-x 66k john 1 Jul 2023 dtb.bin
.rw-r--r-- 33k john 1 Jul 2023 dtb_stripped.bin
.rw-r--r-- 31k john 1 Jul 2023 felnand.config
.rwxr-xr-x 14k john 1 Jul 2023 fsbl.bin
.rwxr-xr-x 240 john 1 Jul 2023 run.bat
.rwxr-xr-x 281 john 1 Jul 2023 run.sh
.rwxr-xr-x 2.0M john 1 Jul 2023 sunxi-fel.exe
.rw-r--r-- 852k john 1 Jul 2023 ub.bin
.rwxr-xr-x 852k john 1 Jul 2023 ub_full.bin
.rw-r--r-- 3.9M john 9 Aug 19:02 uImage
```
And then executed `run.sh` as root:
```shell
sudo su # [tl! .cmd]
./run.sh # [tl! .cmd_root]
waiting for 3 seconds # [tl! .nocopy:4]
100% [================================================] 852 kB, 152.2 kB/s
100% [================================================] 66 kB, 162.1 kB/s
100% [================================================] 0 kB, 93.2 kB/s
100% [================================================] 3647 kB, 153.6 kB/s
```
After a minute or two the robot rebooted and began broadcasting its own Wi-Fi AP. I connected to that, and then used my RSA key to log in via SSH:
```shell
ssh -i ~/.ssh/id_rsa-robot root@192.168.8.1 # [tl! .cmd]
[root@rockrobo ~]# # [tl! .nocopy]
```
At this point, the robot was temporarily running on a rooted live image with [busybox](https://busybox.net/about.html) to provide a lot of shell functionality; I still needed to install the patched firmware to maintain control though.
I backed up the `nandb` and `nandk` flash partitions, which apparently contain unique calibration and identity data that can't be recovered if I break something:
```shell
dd if=/dev/nandb | gzip > /tmp/nandb.img.gz # [tl! .cmd_root:1]
dd if=/dev/nandk | gzip > /tmp/nandk.img.gz
```
I spawned another shell from my laptop to retrieve those important files:
```shell
scp -O -i ~/.ssh/id_rsa-robot root@192.168.8.1:/tmp/nand* . # [tl! .cmd]
```
Back on the robot's shell, I deleted some logs to make sure I'd have enough space to load the new firmware image:
```shell
rm -rf /mnt/data/rockrobo/rrlog/* # [tl! .cmd_root]
```
And used the laptop's shell to transfer over the firmware image:
```shell
scp -O -i ~/.ssh/id_rsa-robot roborock.vacuum.s5e_1668_fw.tar.gz root@192.168.8.1:/mnt/data/ # [tl! .cmd]
```
I switched back to the robot session, extracted the firmware package, ran the installer, and rebooted the robot once it was complete:
```shell
cd /mnt/data/ # [tl! .cmd_root:2]
tar xvzf roborock.vacuum.s5e_1668_fw.tar.gz
./install.sh
reboot # [tl! .cmd_root]
```
After the reboot, I reconnected to the robot's AP, logged back in to SSH, and ran the install again, followed by another reboot:
```shell
cd /mnt/data/ # [tl! .cmd_root:1]
./install.sh
reboot # [tl! .cmd_root]
```
I reconnected to the robot's AP once again, and transferred the Valetudo binary to the robot:
```shell
scp -O -i ~/.ssh/id_rsa-robot valetudo-armv7-lowmem.upx root@192.168.8.1:/mnt/data/valetudo # [tl! .cmd]
```
And then I logged back into the robot via SSH, cleaned up some of the installation files, configured Valetudo to automatically start at boot, and rebooted once again:
```shell
ssh -i ~/.ssh/id_rsa-robot root@192.168.8.1 # [tl! .cmd]
cd /mnt/data # [tl! .cmd_root:3]
rm roborock.vacuum.*.gz boot.img firmware.md5sum rootfs.img install.sh
cp /root/_root.sh.tpl /mnt/reserve/_root.sh
chmod +x /mnt/reserve/_root.sh /mnt/data/valetudo
reboot # [tl! .cmd_root]
```
I connected to the robot's AP for the last time, and pointed my web browser to `http://192.168.8.1/` to start the Valetudo setup process.
![Valetudo web app prompting the user to scan for and connect to the home Wi-Fi network.](valetudo-wifi.png)
After the robot successfully connected to my Wi-Fi network, I was able to use the [Valetudo Companion Android app](https://f-droid.org/packages/cloud.valetudo.companion/) to easily find the robot on my network. I could then connect to its web interface at `http://192.168.1.172/` and see the fruits of my labor:
![The Valetudo web interface showing a map of my house as well as some information about the robot.](valetudo_map.jpg)
The hack was a success!
### Tailscale time
Needing to find the robot's IP before I can interact with it is kind of lame. I'd rather leverage Tailscale's [MagicDNS](https://tailscale.com/kb/1081/magicdns) so that I can connect to the robot without knowing its IP (or even being on the same network).
That Tailscale blog post I mentioned earlier offered some [notes on installing Tailscale on a robot vacuum](https://tailscale.dev/blog/tailscale-sucks#:~:text=Getting%20Tailscale%20on%20the%20robot), which I used as a guide for my efforts.
There's plenty (~260MB) of free space on the robot's `/mnt/data/` persistent partition so I logged back in via SSH and did all the work there, starting with downloading the appropriate ARM package from [Tailscale's package server](https://pkgs.tailscale.com/stable/#static)
```shell
ssh -i ~/.ssh/id_rsa-robot root@192.168.1.169 # [tl! .cmd]
cd /mnt/data # [tl! .cmd_root:1]
wget --no-check-certificate https://pkgs.tailscale.com/stable/tailscale_1.72.1_arm.tgz
```
Then I extracted the binaries, moved them to an appropriate location, and cleaned up the downloaded package:
```shell
tar xvf tailscale_1.72.1_arm.tgz # [tl! .cmd_root:1]
mv tailscale_1.72.1_arm/tailscale \
tailscale_1.72.1_arm/tailscaled \
/mnt/data/
rm -rf tailscale_1.72.1_arm* # [tl! .cmd_root]
```
Remember that `/mnt/reserve/_root.sh` script that I copied earlier so that Valetudo would run at boot? I can hijack that to launch Tailscale as well.
Here's the original script:
```shell
# torchlight! {"lineNumbers":true}
#!/bin/bash
if [[ -f /mnt/data/valetudo ]]; then
mkdir -p /mnt/data/miio/
if grep -q -e "cfg_by=tuya" -e "cfg_by=rriot" /mnt/data/miio/wifi.conf; then
sed -i "s/cfg_by=tuya/cfg_by=miot/g" /mnt/data/miio/wifi.conf
sed -i "s/cfg_by=rriot/cfg_by=miot/g" /mnt/data/miio/wifi.conf
echo region=de >> /mnt/data/miio/wifi.conf
echo 0 > /mnt/data/miio/device.uid
echo "de" > /mnt/data/miio/device.country
fi
# Delete useless cleanup logs on each boot to enable Valetudo to update itself
rm -r /mnt/data/rockrobo/rrlog/*REL
VALETUDO_CONFIG_PATH=/mnt/data/valetudo_config.json /mnt/data/valetudo >> /dev/null 2>&1 &
fi
### It is strongly recommended that you put your changes inside the IF-statement above. In case your changes cause a problem, a factory reset will clean the data partition and disable your chances.
### Keep in mind that your robot model does not have a recovery partition. A bad script can brick your device!
```
See that note at the bottom? Sounds like a good idea, so I'll put my Tailscale code inside the existing `if` statement:
```shell
# torchlight! {"lineNumbers":true}
#!/bin/bash
if [[ -f /mnt/data/valetudo ]]; then
mkdir -p /mnt/data/miio/
if grep -q -e "cfg_by=tuya" -e "cfg_by=rriot" /mnt/data/miio/wifi.conf; then
sed -i "s/cfg_by=tuya/cfg_by=miot/g" /mnt/data/miio/wifi.conf
sed -i "s/cfg_by=rriot/cfg_by=miot/g" /mnt/data/miio/wifi.conf
echo region=de >> /mnt/data/miio/wifi.conf
echo 0 > /mnt/data/miio/device.uid
echo "de" > /mnt/data/miio/device.country
fi
# Delete useless cleanup logs on each boot to enable Valetudo to update itself
rm -r /mnt/data/rockrobo/rrlog/*REL
VALETUDO_CONFIG_PATH=/mnt/data/valetudo_config.json /mnt/data/valetudo >> /dev/null 2>&1 &
# tailscale # [tl! **:7 ++:7]
if [[ -f /mnt/data/tailscaled ]]; then
mkdir -p /mnt/data/tailscale-state /tmp/tailscale
STATE_DIRECTORY=/tmp/tailscale /mnt/data/tailscaled \
--tun=userspace-networking \
--socket=/tmp/tailscale/tailscaled.sock \
--statedir=/mnt/data/tailscale-state > /dev/null 2>&1 &
fi
fi
```
I rebooted the robot, then reconnected and logged in to Tailscale, also setting a custom hostname and enabling [Tailscale SSH](/tailscale-ssh-serve-funnel/#tailscale-ssh) along the way:
```shell
/mnt/data/tailscale --socket=/tmp/tailscale/tailscaled.sock \
up --ssh --hostname=derpmop # [tl! .cmd_root:-1,1]
```
Now I won't have to keep up with the robot's IP address anymore; I can just point a browser to `http://derpmop.tailnet-name.ts.net/`. I can also SSH in without having to specify a key since Tailscale is handling the authentication for me:
```shell
ssh root@derpmop # [tl! .cmd]
```
### Home Assistant integration
- Set up [Mosquito MQTT Broker](https://www.home-assistant.io/integrations/mqtt/#setting-up-a-broker) for Home Assistant.
- Connect Valetudo following [these instructions](https://valetudo.cloud/pages/integrations/home-assistant-integration.html).
- Render map status in HA with [MQTT Vacuum's Camera Add-On](https://github.com/sca075/mqtt_vacuum_camera).
- Make a pretty dashboard card with [Lovelace Vacuum Map Card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card).

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -3,7 +3,7 @@ title: "Secure Networking Made Simple with Tailscale" # Title of the blog post.
date: 2022-01-01 # Date of post creation.
lastmod: 2022-07-10
description: "Tailscale makes it easy to set up and manage a secure network by building a flexible control plane on top of a high-performance WireGuard VPN." # Description used for search engine.
featured: true # Sets if post is a featured post, making appear on the home page side bar.
featured: false
# draft: true # Sets whether to render this page. Draft of true will not be rendered.
toc: true # Controls if a table of contents should be generated for first-level links automatically.
usePageBundles: true

View file

@ -0,0 +1,244 @@
---
title: "SilverBullet: Self-Hosted Knowledge Management Web App"
date: "2024-08-22T02:56:12Z"
# lastmod: 2024-08-12
description: "Deploying SilverBullet with Docker Compose, and accessing it from anywhere with Tailscale and Cloudflare Tunnel."
featured: false
toc: true
reply: true
categories: Self-Hosting
tags:
- cloudflare
- containers
- docker
- linux
- selfhosting
- tailscale
---
I [recently posted on my other blog](https://srsbsns.lol/is-silverbullet-the-note-keeping-silver-bullet/) about trying out [SilverBullet](https://silverbullet.md), an open-source self-hosted web-based note-keeping app. SilverBullet has continued to impress me as I use it and learn more about its [features](https://silverbullet.md/SilverBullet@1992). It really fits my multi-device use case much better than Obsidian ever did (even with its paid sync plugin).
In that post, I shared a brief overview of how I set up SilverBullet:
> I deployed my instance in Docker alongside both a [Tailscale sidecar](/tailscale-serve-docker-compose-sidecar/) and [Cloudflare Tunnel sidecar](/publish-services-cloudflare-tunnel/). This setup lets me easily access/edit/manage my notes from any device I own by just pointing a browser at `https://silverbullet.tailnet-name.ts.net/`. And I can also hit it from any *other* device by using the public Cloudflare endpoint which is further protected by an email-based TOTP challenge. Either way, I don't have to worry about installing a bloated app or managing a complicated sync setup. Just log in and write.
This post will go into a bit more detail about that configuration.
### Preparation
I chose to deploy SilverBullet on an Ubuntu 22.04 VM in my [homelab](/homelab/) which was already set up for serving Docker workloads so I'm not going to cover the Docker [installation process](https://docs.docker.com/engine/install/ubuntu/) here. I tend to run my Docker workloads out of `/opt/` so I start this journey by creating a place to hold the SilverBullet setup:
```shell
sudo mkdir -p /opt/silverbullet # [tl! .cmd]
```
I set appropriate ownership of the folder and then move into it:
```shell
sudo chown john:docker /opt/silverbullet # [tl! .cmd:1]
cd /opt/silverbullet
```
### SilverBullet Setup
The documentation offers easy-to-follow guidance on [installing SilverBullet with Docker Compose](https://silverbullet.md/Install/Docker), and that makes for a pretty good starting point. The only change I make here is setting the `SB_USER` variable from an environment variable instead of directly in the YAML:
```yaml
# torchlight! {"lineNumbers":true}
# docker-compose.yml
services:
silverbullet:
image: zefhemel/silverbullet
container_name: silverbullet
restart: unless-stopped
environment:
SB_USER: "${SB_CREDS}"
volumes:
- ./space:/space
ports:
- 3000:3000
watchtower:
image: containrrr/watchtower
container_name: silverbullet-watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
I used a password manager to generate a random password *and username*, and I stored those in a `.env` file alongside the Docker Compose configuration; I'll need those credentials to log in to each SilverBullet session. For example:
```shell
# .env
SB_CREDS='alldiaryriver:XCTpmddGc3Ga4DkUr7DnPBYzt1b'
```
That's all that's needed for running SilverBullet locally, and I *could* go ahead and `docker compose up -d` to get it running. But I really want to be able to access my notes from other systems too, so let's move on to enabling remote access right away.
### Remote Access
#### Tailscale
It's no secret that I'm a [big fan of Tailscale](/secure-networking-made-simple-with-tailscale/) so I use Tailscale Serve to enable secure remote access through my tailnet. I just need to add in a [Tailscale sidecar](/tailscale-serve-docker-compose-sidecar/#compose-configuration) and update the `silverbullet` service to share Tailscale's network:
```yaml
# torchlight! {"lineNumbers":true}
# docker-compose.yml
services:
tailscale: # [tl! ++:12 **:12]
image: tailscale/tailscale:latest
container_name: silverbullet-tailscale
restart: unless-stopped
environment:
TS_AUTHKEY: ${TS_AUTHKEY:?err}
TS_HOSTNAME: ${TS_HOSTNAME:-ts-docker}
TS_EXTRA_ARGS: ${TS_EXTRA_ARGS:-}
TS_STATE_DIR: /var/lib/tailscale/
TS_SERVE_CONFIG: /config/serve-config.json
volumes:
- ./ts_data:/var/lib/tailscale/
- ./serve-config.json:/config/serve-config.json
silverbullet:
image: zefhemel/silverbullet
container_name: silverbullet
restart: unless-stopped
environment:
SB_USER: "${SB_CREDS}"
volumes:
- ./space:/space
ports: # [tl! --:1 **:1]
- 3000:3000
network_mode: service:tailscale # [tl! ++ **]
watchtower: # [tl! collapse:4]
image: containrrr/watchtower
container_name: silverbullet-watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
That of course means adding a few more items to the `.env` file:
- a [pre-authentication key](https://tailscale.com/kb/1085/auth-keys),
- the hostname to use for the application's presence on my tailnet,
- and the `--ssh` extra argument to enable SSH access to the container (not strictly necessary, but can be handy for troubleshooting).
```shell
# .env
SB_CREDS='alldiaryriver:XCTpmddGc3Ga4DkUr7DnPBYzt1b'
TS_AUTHKEY=tskey-auth-[...] # [tl! ++:2 **:2]
TS_HOSTNAME=silverbullet
TS_EXTRA_ARGS=--ssh
```
And I need to create a `serve-config.json` file to configure [Tailscale Serve](/tailscale-ssh-serve-funnel/#tailscale-serve) to proxy port `443` on the tailnet to port `3000` on the container:
```json
// torchlight! {"lineNumbers":true}
// serve-config.json
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"silverbullet.tailnet-name.ts.net:443": {
"Handlers": {
"/": {
"Proxy": "http://127.0.0.1:3000"
}
}
}
}
}
```
#### Cloudflare Tunnel
But what if I want to consult my notes from *outside* of my tailnet? Sure, I *could* use [Tailscale Funnel](/tailscale-ssh-serve-funnel/#tailscale-funnel) to publish the SilverBullet service on the internet, but (1) funnel would require me to use a URL like `https://silverbullet.tailnet-name.ts.net` instead of simply `https://silverbullet.example.com` and (2) I've seen enough traffic logs to not want to expose a login page directly to the public internet if I can avoid it.
[Cloudflare Tunnel](/publish-services-cloudflare-tunnel/) is able to address those concerns without a lot of extra work. I can set up a tunnel at `silverbullet.example.com` and use [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/) to put an additional challenge in front of the login page.
I just have to add a `cloudflared` container to my stack:
```yaml
# torchlight! {"lineNumbers":true}
# docker-compose.yml
services:
tailscale: # [tl! collapse:12]
image: tailscale/tailscale:latest
container_name: silverbullet-tailscale
restart: unless-stopped
environment:
TS_AUTHKEY: ${TS_AUTHKEY:?err}
TS_HOSTNAME: ${TS_HOSTNAME:-ts-docker}
TS_EXTRA_ARGS: ${TS_EXTRA_ARGS:-}
TS_STATE_DIR: /var/lib/tailscale/
TS_SERVE_CONFIG: /config/serve-config.json
volumes:
- ./ts_data:/var/lib/tailscale/
- ./serve-config.json:/config/serve-config.json
cloudflared: # [tl! ++:9 **:9]
image: cloudflare/cloudflared
restart: unless-stopped
container_name: silverbullet-cloudflared
command:
- tunnel
- run
- --token
- ${CLOUDFLARED_TOKEN}
network_mode: service:tailscale
silverbullet:
image: zefhemel/silverbullet
container_name: silverbullet
restart: unless-stopped
environment:
SB_USER: "${SB_CREDS}"
volumes:
- ./space:/space
network_mode: service:tailscale
watchtower: # [tl! collapse:4]
image: containrrr/watchtower
container_name: silverbullet-watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
To get the required `$CLOUDFLARED_TOKEN`, I [create a new `cloudflared` tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel/) in the Cloudflare dashboard and add the generated token value to my `.env` file:
```shell
# .env
SB_CREDS='alldiaryriver:XCTpmddGc3Ga4DkUr7DnPBYzt1b'
TS_AUTHKEY=tskey-auth-[...]
TS_HOSTNAME=silverbullet
TS_EXTRA_ARGS=--ssh
CLOUDFLARED_TOKEN=eyJhIjo[...]BNSJ9 # [tl! ++ **]
```
Back in the Cloudflare Tunnel setup flow, I select my desired public hostname (`silverbullet.example.com`) and then specify that the backend service is `http://localhost:3000`.
Now I'm finally ready to start up my containers:
```shell
docker compose up -d # [tl! .cmd .nocopy:1,5]
[+] Running 5/5
✔ Network silverbullet_default Created
✔ Container silverbullet-watchtower Started
✔ Container silverbullet-tailscale Started
✔ Container silverbullet Started
✔ Container silverbullet-cloudflared Started
```
#### Cloudflare Access
The finishing touch will be configuring a bit of extra protection in front of the public-facing login page, and Cloudflare Access makes that very easy. I'll just use the wizard to [add a new web application](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/) through the Cloudflare Zero Trust dashboard.
The first part of that workflow asks "What type of application do you want to add?". I select **Self-hosted**.
The next part asks for a name (**SilverBullet**), Session Duration (**24 hours**), and domain (`silverbullet.example.com`). I leave the defaults for the rest of the Configuration Application step and move on to the next one.
I'm then asked to Add Policies, and I have to start by giving a name for my policy. I opt to name it **Email OTP** because I'm going to set up email-based one-time passcodes. In the Configure Rules section, I choose **Emails** as the selector and enter my own email address as the single valid value.
And then I just click through the rest of the defaults.
### Recap
So now I have SilverBullet running in Docker Compose on a server in my homelab. I can access it from any device on my tailnet at `https://silverbullet.tailnet-name.ts.net` (thanks to the magic of Tailscale Serve). I can also get to it from outside my tailnet at `https://silverbullet.example.com` (thanks to Cloudflare Tunnel), and but I'll use a one-time passcode sent to my approved email address before also authenticating through the SilverBullet login page (thanks to Cloudflare Access).
I think it's a pretty sweet setup that gives me full control and ownership of my notes and lets me read/write my notes from multiple devices without having to worry about synchronization.

View file

@ -3,6 +3,7 @@ categories: Tips
date: "2021-02-18T08:34:30Z"
thumbnail: PPZu_UOGO.png
usePageBundles: true
featured: true
tags:
- logs
- vmware

View file

@ -0,0 +1,4 @@
<h{{.Level}} id="{{.Anchor | safeURL}}">
{{.Text | safeHTML}}
<a class="hlink" href="#{{.Anchor | safeURL}}"><i class="fa-solid fa-link"></i></a>
</h{{.Level}}>

View file

@ -26,7 +26,7 @@
{{- if and (gt $ageDays 365) (not .Params.timeless) -}}
<br>
<div class="notice note">
<p class="first notice-title"><span class="icon-notice baseline"><svg><use href="#note-notice"></use></svg></span>Technology keeps moving but this post has not.</p>
<p class="first notice-title"><span class="icon-notice baseline"><svg id="note-notice" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/></svg></span>Technology keeps moving but this post has not.</p>
What you're about to read hasn't been updated in more than a year. The information may be out of date. Let me know if you see anything that needs fixing.
</div>
{{- end -}}

View file

@ -1,7 +1,7 @@
{{ with .Site.Params.about }}
<div class="aside__about">
{{ with .logo }}<img class="about__logo" src="{{ . | absURL }}" alt="Logo">{{ end }}
<h1 class="about__title">{{ .title }}&nbsp;<a href="/feed.xml" aria-label="RSS"><i class="fa-solid fa-square-rss"></i></a>&nbsp;</h1>
<h1 class="about__title">{{ .title }}&nbsp;<a href="/feed.xml" aria-label="RSS"><i class="fa-solid fa-square-rss"></i></a></h1>
{{ partial "tagline.html" . }}
<br>
<a href="/about/"><i class="fa-regular fa-user"></i></a>&nbsp;<a href="/about/">{{ site.Params.Author.name }}</a>

View file

@ -4,7 +4,7 @@
{{- errorf "%s" . -}}
{{- else -}}
{{- $robots := unmarshal .Content -}}
{{- range $botname, $props := $robots }}
{{- range $botname, $_ := $robots }}
{{- printf "User-agent: %s\n" $botname }}
{{- end }}
{{- printf "Disallow: /\n" }}

View file

@ -15,3 +15,7 @@
{{ $jsCopy := resources.Get "js/code-copy-button.js" | minify }}
<script src="{{ $jsCopy.RelPermalink }}" async></script>
{{ end }}
<!-- theme switch -->
{{ $themeToggle := resources.Get "js/set-theme.js" | minify }}
<script src="{{ $themeToggle.RelPermalink }}"></script>

View file

@ -21,8 +21,20 @@
{{ partialCached "favicon" . }}
{{ partial "opengraph" . }}
<!-- FontAwesome <https://fontawesome.com/> -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.2/css/all.min.css" integrity="sha512-1sCRPdkRXhBV2PBLUdRb4tMg1w2YPf37qatUFeS7zlBy7jJI8Lf4VHwWfZZfpXtYSLy85pkm9GaYVYMfw5BC1A==" crossorigin="anonymous" />
<!-- load theme preference asap -->
<script>
(function() {
var savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg==" crossorigin="anonymous" />
<!-- ForkAwesome <https://forkaweso.me/> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/css/fork-awesome.min.css" integrity="sha256-XoaMnoYC5TH6/+ihMEnospgm0J1PM/nioxbOUdnM8HY=" crossorigin="anonymous">

View file

@ -7,4 +7,16 @@
{{ end }}
</ul>
</nav>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle light/dark theme" title="Toggle light/dark theme">
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<span class="theme-toggle__icon theme-toggle__icon--sun">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"/>
</svg>
</span>
<span class="theme-toggle__icon theme-toggle__icon--moon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"/>
</svg>
</span>
</button>

View file

@ -1,9 +1,129 @@
/* color and font overrides */
/* define fonts */
:root {
--code: var(--base06);
--font-monospace: 'Berkeley Mono', 'IBM Plex Mono', 'Cascadia Mono', 'Roboto Mono', 'Source Code Pro', 'Fira Mono', 'Courier New', monospace;
}
/* dark/light theming */
:root{
/* Default dark theme */
--bg: var(--dark-base00);
--code: var(--dark-base06);
--fg: var(--dark-base05);
--highlight: var(--dark-base0A);
--hover: var(--dark-base0C);
--inner-bg: var(--dark-base02);
--link: var(--dark-base0D);
--logo-text: var(--dark-base09);
--logo: var(--dark-base0B);
--muted: var(--dark-base03);
--off-bg: var(--dark-base01);
--off-fg: var(--dark-base04);
--root-prompt: var(--dark-base08);
--user-prompt: var(--dark-base07);
}
:root[data-theme="light"] {
--bg: var(--light-base00);
--off-bg: var(--light-base01);
--inner-bg: var(--light-base02);
--muted: var(--light-base03);
--off-fg: var(--light-base04);
--fg: var(--light-base05);
--code: var(--light-base06);
--user-prompt: var(--light-base07);
--root-prompt: var(--light-base08);
--logo-text: var(--light-base09);
--highlight: var(--light-base0A);
--logo: var(--light-base0B);
--hover: var(--light-base0C);
--link: var(--light-base0D);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: var(--light-base00);
--off-bg: var(--light-base01);
--inner-bg: var(--light-base02);
--muted: var(--light-base03);
--off-fg: var(--light-base04);
--fg: var(--light-base05);
--code: var(--light-base06);
--user-prompt: var(--light-base07);
--root-prompt: var(--light-base08);
--logo-text: var(--light-base09);
--highlight: var(--light-base0A);
--logo: var(--light-base0B);
--hover: var(--light-base0C);
--link: var(--light-base0D);
}
}
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
cursor: pointer;
padding: 0;
width: 1.5rem;
height: 1.5rem;
z-index: 1000;
transition: opacity 0.3s ease;
}
.theme-toggle__icon {
width: 100%;
height: 100%;
transition: opacity 0.3s ease;
}
.theme-toggle__icon svg {
width: 100%;
height: 100%;
fill: var(--off-fg);
transition: fill 0.3s ease;
}
.theme-toggle:hover .theme-toggle__icon svg {
fill: var(--hover);
}
.theme-toggle__icon--sun,
[data-theme="dark"] .theme-toggle__icon--moon {
display: none;
}
[data-theme="dark"] .theme-toggle__icon--sun,
.theme-toggle__icon--moon {
display: block;
}
.theme-toggle:hover {
opacity: 0.7;
}
.theme-toggle:focus {
outline: none;
}
.theme-toggle:focus-visible {
outline: 2px solid var(--hover);
outline-offset: 2px;
}
.theme-toggle:hover .theme-toggle__icon {
fill: var(--hover);
}
.page__nav ul {
padding-right: 3rem;
}
/* load preferred font */
@font-face {
font-family: 'Berkeley Mono';
@ -30,7 +150,7 @@
/* logo tweaks */
.page__logo {
color: var(--off-fg);
color: var(--logo-text);
}
.page__logo-inner {
@ -70,59 +190,24 @@
/* Notice CSS Built on hugo-notice by Nicolas Martignoni: https://github.com/martignoni/hugo-notice */
.notice {
--root-color: #444;
--root-background: #eff;
--title-color: #fff;
--title-background: #7bd;
--warning-title: #c33;
--warning-content: #fee;
--info-title: #fb7;
--info-content: #fec;
--note-title: #6be;
--note-content: #e7f2fa;
--tip-title: #5a5;
--tip-content: #efe;
}
--notice-title-color: #fff;
--notice-warn-color: #c33;
--notice-info-color: #fb7;
--notice-note-color: #6be;
--notice-tip-color: #5a5;
@media (prefers-color-scheme: dark) {
.notice {
--root-color: #ddd;
--root-background: #eff;
--title-color: #fff;
--title-background: #7bd;
--warning-title: #800;
--warning-content: #400;
--info-title: #a50;
--info-content: #420;
--note-title: #069;
--note-content: #023;
--tip-title: #363;
--tip-content: #121;
}
}
--notice-padding: 18px;
--notice-line-height: 24px;
--notice-margin-bottom: 24px;
--notice-border-radius: 4px;
--notice-title-margin: 12px;
--notice-bg-opacity: 10%;
body.dark .notice {
--root-color: #ddd;
--root-background: #eff;
--title-color: #fff;
--title-background: #7bd;
--warning-title: #800;
--warning-content: #400;
--info-title: #a50;
--info-content: #420;
--note-title: #069;
--note-content: #023;
--tip-title: #363;
--tip-content: #121;
}
.notice {
padding: 18px;
line-height: 24px;
margin-bottom: 24px;
border-radius: 4px;
color: var(--root-color);
background: var(--root-background);
padding: var(--notice-padding);
line-height: var(--notice-line-height);
margin-bottom: var(--notice-margin-bottom);
border-radius: var(--notice-border-radius);
color: var(--fg);
}
.notice p:last-child {
@ -130,45 +215,23 @@ body.dark .notice {
}
.notice-title {
margin: -18px -18px 12px;
padding: 4px 18px;
border-radius: 4px 4px 0 0;
margin: calc(-1 * var(--notice-padding));
margin-bottom: var(--notice-title-margin);
padding: 4px var(--notice-padding);
border-radius: var(--notice-border-radius) var(--notice-border-radius) 0 0;
font-weight: 700;
color: var(--title-color);
background: var(--title-background);
color: var(--notice-title-color);
}
.notice.warning .notice-title {
background: var(--warning-title);
}
.notice.warning .notice-title { background: var(--notice-warning-color); }
.notice.info .notice-title { background: var(--notice-info-color); }
.notice.note .notice-title { background: var(--notice-note-color); }
.notice.tip .notice-title { background: var(--notice-tip-color); }
.notice.warning {
background: var(--warning-content);
}
.notice.info .notice-title {
background: var(--info-title);
}
.notice.info {
background: var(--info-content);
}
.notice.note .notice-title {
background: var(--note-title);
}
.notice.note {
background: var(--note-content);
}
.notice.tip .notice-title {
background: var(--tip-title);
}
.notice.tip {
background: var(--tip-content);
}
.notice.warning { background: color-mix(in srgb, var(--notice-warning-color) var(--notice-bg-opacity), transparent); }
.notice.info { background: color-mix(in srgb, var(--notice-info-color) var(--notice-bg-opacity), transparent); }
.notice.note { background: color-mix(in srgb, var(--notice-note-color) var(--notice-bg-opacity), transparent); }
.notice.tip { background: color-mix(in srgb, var(--notice-tip-color) var(--notice-bg-opacity), transparent); }
.icon-notice {
display: inline-flex;
@ -463,3 +526,19 @@ p:has(+ ul) {
.kudos-text.thanks {
font-style: italic;
}
/* Header anchor links */
.hlink {
opacity: 0.4;
color: var(--muted) !important;
}
h1:hover .hlink,
h2:hover .hlink,
h3:hover .hlink,
h4:hover .hlink,
h5:hover .hlink,
h6:hover .hlink {
opacity: 0.8;
color: var(--link) !important;
}

View file

@ -2,20 +2,35 @@
*/
:root {
--base00: #090909; /* bg */
--base01: #1c1c1c; /* off-bg */
--base02: #292929; /* inner-bg */
--base03: #6d6c6c; /* muted */
--base04: #abaaaa; /* off-fg */
--base05: #d8d8d8; /* fg */
--base06: #75f558; /* code */
--base07: #5f8700; /* user prompt */
--base08: #ab4642; /* root prompt */
--base09: #dc9656;
--base0A: #f7ca88; /* highlight */
--base0B: #682523; /* logo */
--base0C: #ab2321; /* hover */
--base0D: #d36060; /* link */
--base0E: #ba8baf;
--base0F: #a16946;
/* dark theme colors */
--dark-base00: #090909; /* bg */
--dark-base01: #1c1c1c; /* off-bg */
--dark-base02: #292929; /* inner-bg */
--dark-base03: #6d6c6c; /* muted */
--dark-base04: #abaaaa; /* off-fg */
--dark-base05: #d8d8d8; /* fg */
--dark-base06: #75f558; /* code */
--dark-base07: #5f8700; /* user prompt */
--dark-base08: #ab4642; /* root prompt */
--dark-base09: #abaaaa; /* logo text */
--dark-base0A: #f7ca88; /* highlight */
--dark-base0B: #682523; /* logo */
--dark-base0C: #ab2321; /* hover */
--dark-base0D: #d36060; /* link */
/* light theme colors */
--light-base00: #ffffff; /* bg */
--light-base01: #f0f0f0; /* off-bg */
--light-base02: #dbdbdb; /* inner-bg */
--light-base03: #909090; /* muted */
--light-base04: #707070; /* off-fg */
--light-base05: #303030; /* fg */
--light-base06: #2a8f1f; /* code */
--light-base07: #3f5900; /* user prompt */
--light-base08: #c23d3d; /* root prompt */
--light-base09: #f0f0f0; /* logo-text */
--light-base0A: #d4a960; /* highlight */
--light-base0B: #af3a37; /* logo */
--light-base0C: #c62a28; /* hover */
--light-base0D: #a04545; /* link */
}

View file

@ -11,7 +11,7 @@ module.exports = {
// Which theme you want to use. You can find all of the themes at
// https://torchlight.dev/docs/themes.
theme: 'synthwave-84',
theme: 'material-theme-lighter',
// The Host of the API.
host: 'https://api.torchlight.dev',