Compare commits

..

No commits in common. "190e28d89525d146d9e13468d5e770ca0175d052" and "010517ba3a2b21717b622d267b97af69f2bde1cd" have entirely different histories.

44 changed files with 119 additions and 1099 deletions

View file

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

View file

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

View file

@ -1,7 +1,7 @@
--- ---
title: "/changelog" title: "/changelog"
date: "2024-05-26T21:19:08Z" date: "2024-05-26T21:19:08Z"
lastmod: "2024-08-21T03:11:27Z" lastmod: "2024-08-04T22:30:43Z"
description: "Maybe I should keep a log of all my site-related tinkering?" description: "Maybe I should keep a log of all my site-related tinkering?"
featured: false featured: false
toc: false toc: false
@ -10,12 +10,6 @@ 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/).* *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:** **2024-08-04:**
- Dynamically build `robots.txt` based on [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) - 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" title: "/homelab"
date: "2024-05-26T21:30:51Z" date: "2024-05-26T21:30:51Z"
lastmod: "2024-08-13T02:12:54Z" lastmod: "2024-06-14T01:44:01Z"
aliases: aliases:
- playground - playground
description: "The systems I use for fun and enrichment." description: "The systems I use for fun and enrichment."
@ -37,7 +37,6 @@ The Proxmox cluster hosts a number of VMs and LXC containers:
- [Hashicorp Vault](https://www.vaultproject.io/) for secrets management - [Hashicorp Vault](https://www.vaultproject.io/) for secrets management
- [Miniflux](https://miniflux.app/) feed reader - [Miniflux](https://miniflux.app/) feed reader
- [RIPE Atlas Probe](https://www.ripe.net/analyse/internet-measurements/ripe-atlas/) for measuring internet connectivity - [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/)) - [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) - `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/)) - `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,7 +4,6 @@ date: "2018-09-26T08:34:30Z"
lastmod: "2022-03-06" lastmod: "2022-03-06"
thumbnail: i0UKdXleC.png thumbnail: i0UKdXleC.png
usePageBundles: true usePageBundles: true
featured: true
tags: tags:
- docker - docker
- linux - linux

View file

@ -1,190 +0,0 @@
---
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 lastmod: 2023-08-31
timeless: true timeless: true
description: There are no dumb questions - but there are smarter (and dumber) ways to ask them. description: There are no dumb questions - but there are smarter (and dumber) ways to ask them.
featured: false featured: true
aliases: ["how2ask"] aliases: ["how2ask"]
categories: Tips categories: Tips
--- ---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

View file

@ -1,368 +0,0 @@
---
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.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

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. date: 2022-01-01 # Date of post creation.
lastmod: 2022-07-10 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. 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: false featured: true # Sets if post is a featured post, making appear on the home page side bar.
# draft: true # Sets whether to render this page. Draft of true will not be rendered. # 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. toc: true # Controls if a table of contents should be generated for first-level links automatically.
usePageBundles: true usePageBundles: true

View file

@ -1,244 +0,0 @@
---
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,7 +3,6 @@ categories: Tips
date: "2021-02-18T08:34:30Z" date: "2021-02-18T08:34:30Z"
thumbnail: PPZu_UOGO.png thumbnail: PPZu_UOGO.png
usePageBundles: true usePageBundles: true
featured: true
tags: tags:
- logs - logs
- vmware - vmware

View file

@ -1,4 +0,0 @@
<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) -}} {{- if and (gt $ageDays 365) (not .Params.timeless) -}}
<br> <br>
<div class="notice note"> <div class="notice note">
<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> <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>
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. 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> </div>
{{- end -}} {{- end -}}

View file

@ -1,7 +1,7 @@
{{ with .Site.Params.about }} {{ with .Site.Params.about }}
<div class="aside__about"> <div class="aside__about">
{{ with .logo }}<img class="about__logo" src="{{ . | absURL }}" alt="Logo">{{ end }} {{ 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></h1> <h1 class="about__title">{{ .title }}&nbsp;<a href="/feed.xml" aria-label="RSS"><i class="fa-solid fa-square-rss"></i></a>&nbsp;</h1>
{{ partial "tagline.html" . }} {{ partial "tagline.html" . }}
<br> <br>
<a href="/about/"><i class="fa-regular fa-user"></i></a>&nbsp;<a href="/about/">{{ site.Params.Author.name }}</a> <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" . -}} {{- errorf "%s" . -}}
{{- else -}} {{- else -}}
{{- $robots := unmarshal .Content -}} {{- $robots := unmarshal .Content -}}
{{- range $botname, $_ := $robots }} {{- range $botname, $props := $robots }}
{{- printf "User-agent: %s\n" $botname }} {{- printf "User-agent: %s\n" $botname }}
{{- end }} {{- end }}
{{- printf "Disallow: /\n" }} {{- printf "Disallow: /\n" }}

View file

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

View file

@ -21,20 +21,8 @@
{{ partialCached "favicon" . }} {{ partialCached "favicon" . }}
{{ partial "opengraph" . }} {{ partial "opengraph" . }}
<!-- load theme preference asap --> <!-- FontAwesome <https://fontawesome.com/> -->
<script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.2/css/all.min.css" integrity="sha512-1sCRPdkRXhBV2PBLUdRb4tMg1w2YPf37qatUFeS7zlBy7jJI8Lf4VHwWfZZfpXtYSLy85pkm9GaYVYMfw5BC1A==" crossorigin="anonymous" />
(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/> --> <!-- 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"> <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,16 +7,4 @@
{{ end }} {{ end }}
</ul> </ul>
</nav> </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,129 +1,9 @@
/* define fonts */ /* color and font overrides */
:root { :root {
--code: var(--base06);
--font-monospace: 'Berkeley Mono', 'IBM Plex Mono', 'Cascadia Mono', 'Roboto Mono', 'Source Code Pro', 'Fira Mono', 'Courier New', monospace; --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 */ /* load preferred font */
@font-face { @font-face {
font-family: 'Berkeley Mono'; font-family: 'Berkeley Mono';
@ -150,7 +30,7 @@ body {
/* logo tweaks */ /* logo tweaks */
.page__logo { .page__logo {
color: var(--logo-text); color: var(--off-fg);
} }
.page__logo-inner { .page__logo-inner {
@ -190,24 +70,59 @@ body {
/* Notice CSS Built on hugo-notice by Nicolas Martignoni: https://github.com/martignoni/hugo-notice */ /* Notice CSS Built on hugo-notice by Nicolas Martignoni: https://github.com/martignoni/hugo-notice */
.notice { .notice {
--notice-title-color: #fff; --root-color: #444;
--notice-warn-color: #c33; --root-background: #eff;
--notice-info-color: #fb7; --title-color: #fff;
--notice-note-color: #6be; --title-background: #7bd;
--notice-tip-color: #5a5; --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-padding: 18px; @media (prefers-color-scheme: dark) {
--notice-line-height: 24px; .notice {
--notice-margin-bottom: 24px; --root-color: #ddd;
--notice-border-radius: 4px; --root-background: #eff;
--notice-title-margin: 12px; --title-color: #fff;
--notice-bg-opacity: 10%; --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;
}
}
padding: var(--notice-padding); body.dark .notice {
line-height: var(--notice-line-height); --root-color: #ddd;
margin-bottom: var(--notice-margin-bottom); --root-background: #eff;
border-radius: var(--notice-border-radius); --title-color: #fff;
color: var(--fg); --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);
} }
.notice p:last-child { .notice p:last-child {
@ -215,23 +130,45 @@ body {
} }
.notice-title { .notice-title {
margin: calc(-1 * var(--notice-padding)); margin: -18px -18px 12px;
margin-bottom: var(--notice-title-margin); padding: 4px 18px;
padding: 4px var(--notice-padding); border-radius: 4px 4px 0 0;
border-radius: var(--notice-border-radius) var(--notice-border-radius) 0 0;
font-weight: 700; font-weight: 700;
color: var(--notice-title-color); color: var(--title-color);
background: var(--title-background);
} }
.notice.warning .notice-title { background: var(--notice-warning-color); } .notice.warning .notice-title {
.notice.info .notice-title { background: var(--notice-info-color); } background: var(--warning-title);
.notice.note .notice-title { background: var(--notice-note-color); } }
.notice.tip .notice-title { background: var(--notice-tip-color); }
.notice.warning { background: color-mix(in srgb, var(--notice-warning-color) var(--notice-bg-opacity), transparent); } .notice.warning {
.notice.info { background: color-mix(in srgb, var(--notice-info-color) var(--notice-bg-opacity), transparent); } background: var(--warning-content);
.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); }
.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);
}
.icon-notice { .icon-notice {
display: inline-flex; display: inline-flex;
@ -525,20 +462,4 @@ p:has(+ ul) {
.kudos-text.thanks { .kudos-text.thanks {
font-style: italic; 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,35 +2,20 @@
*/ */
:root { :root {
/* dark theme colors */ --base00: #090909; /* bg */
--dark-base00: #090909; /* bg */ --base01: #1c1c1c; /* off-bg */
--dark-base01: #1c1c1c; /* off-bg */ --base02: #292929; /* inner-bg */
--dark-base02: #292929; /* inner-bg */ --base03: #6d6c6c; /* muted */
--dark-base03: #6d6c6c; /* muted */ --base04: #abaaaa; /* off-fg */
--dark-base04: #abaaaa; /* off-fg */ --base05: #d8d8d8; /* fg */
--dark-base05: #d8d8d8; /* fg */ --base06: #75f558; /* code */
--dark-base06: #75f558; /* code */ --base07: #5f8700; /* user prompt */
--dark-base07: #5f8700; /* user prompt */ --base08: #ab4642; /* root prompt */
--dark-base08: #ab4642; /* root prompt */ --base09: #dc9656;
--dark-base09: #abaaaa; /* logo text */ --base0A: #f7ca88; /* highlight */
--dark-base0A: #f7ca88; /* highlight */ --base0B: #682523; /* logo */
--dark-base0B: #682523; /* logo */ --base0C: #ab2321; /* hover */
--dark-base0C: #ab2321; /* hover */ --base0D: #d36060; /* link */
--dark-base0D: #d36060; /* link */ --base0E: #ba8baf;
--base0F: #a16946;
/* 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 // Which theme you want to use. You can find all of the themes at
// https://torchlight.dev/docs/themes. // https://torchlight.dev/docs/themes.
theme: 'material-theme-lighter', theme: 'synthwave-84',
// The Host of the API. // The Host of the API.
host: 'https://api.torchlight.dev', host: 'https://api.torchlight.dev',