Compare commits
No commits in common. "190e28d89525d146d9e13468d5e770ca0175d052" and "010517ba3a2b21717b622d267b97af69f2bde1cd" have entirely different histories.
190e28d895
...
010517ba3a
|
@ -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> ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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());
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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/))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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!
|
|
|
@ -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
|
||||||
---
|
---
|
||||||
|
|
Before Width: | Height: | Size: 364 KiB |
Before Width: | Height: | Size: 364 KiB |
Before Width: | Height: | Size: 364 KiB |
Before Width: | Height: | Size: 253 KiB |
Before Width: | Height: | Size: 286 KiB |
Before Width: | Height: | Size: 413 KiB |
Before Width: | Height: | Size: 328 KiB |
Before Width: | Height: | Size: 305 KiB |
Before Width: | Height: | Size: 367 KiB |
Before Width: | Height: | Size: 328 KiB |
Before Width: | Height: | Size: 96 KiB |
Before Width: | Height: | Size: 189 KiB |
|
@ -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).
|
|
Before Width: | Height: | Size: 183 KiB |
Before Width: | Height: | Size: 210 KiB |
Before Width: | Height: | Size: 300 KiB |
Before Width: | Height: | Size: 234 KiB |
Before Width: | Height: | Size: 284 KiB |
Before Width: | Height: | Size: 157 KiB |
Before Width: | Height: | Size: 291 KiB |
Before Width: | Height: | Size: 332 KiB |
Before Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 140 KiB |
|
@ -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
|
||||||
|
|
|
@ -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.
|
|
|
@ -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
|
||||||
|
|
|
@ -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}}>
|
|
|
@ -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 -}}
|
||||||
|
|
|
@ -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 }} <a href="/feed.xml" aria-label="RSS"><i class="fa-solid fa-square-rss"></i></a></h1>
|
<h1 class="about__title">{{ .title }} <a href="/feed.xml" aria-label="RSS"><i class="fa-solid fa-square-rss"></i></a> </h1>
|
||||||
{{ partial "tagline.html" . }}
|
{{ partial "tagline.html" . }}
|
||||||
<br>
|
<br>
|
||||||
<a href="/about/"><i class="fa-regular fa-user"></i></a> <a href="/about/">{{ site.Params.Author.name }}</a>
|
<a href="/about/"><i class="fa-regular fa-user"></i></a> <a href="/about/">{{ site.Params.Author.name }}</a>
|
||||||
|
|
|
@ -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" }}
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
|
@ -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 */
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|