1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-11-10 01:20:58 +01:00
This commit is contained in:
Kristoffer Dalby 2025-11-02 13:20:26 +01:00 committed by GitHub
commit a841b8d00d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1251 additions and 519 deletions

View File

@ -1,307 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Headscale Authentication Succeeded</title>
<style>
body {
font-size: 14px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
}
hr {
border-color: #fdfdfe;
margin: 24px 0;
}
.container {
display: flex;
justify-content: center;
align-items: center;
height: 70vh;
}
#logo {
display: block;
margin-left: -20px;
margin-bottom: 16px;
}
.message {
display: flex;
min-width: 40vw;
background: #fafdfa;
border: 1px solid #c6e9c9;
margin-bottom: 12px;
padding: 12px 16px 16px 12px;
position: relative;
border-radius: 2px;
font-size: 14px;
}
.message-content {
margin-left: 4px;
}
.message #checkbox {
fill: #2eb039;
}
.message .message-title {
color: #1e7125;
font-size: 16px;
font-weight: 700;
line-height: 1.25;
}
.message .message-body {
border: 0;
margin-top: 4px;
}
.message p {
font-size: 12px;
margin: 0;
padding: 0;
color: #17421b;
}
a {
display: block;
margin: 8px 0;
color: #1563ff;
text-decoration: none;
font-weight: 600;
}
a:hover {
color: black;
}
a svg {
fill: currentcolor;
}
.icon {
align-items: center;
display: inline-flex;
justify-content: center;
height: 21px;
width: 21px;
vertical-align: middle;
}
h1 {
font-size: 17.5px;
font-weight: 700;
margin-bottom: 0;
}
h1 + p {
margin: 8px 0 16px 0;
}
</style>
</head>
<body translate="no">
<div class="container">
<div>
<svg
id="logo"
width="146"
height="51"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
stroke-miterlimit: 2;
"
viewBox="0 0 1280 640"
>
<path
d="M.08 0v-.736h.068v.3C.203-.509.27-.545.347-.545c.029 0 .055.005.079.015.024.01.045.025.062.045.017.02.031.045.041.075.009.03.014.065.014.105V0H.475v-.289C.475-.352.464-.4.443-.433.422-.466.385-.483.334-.483c-.027 0-.052.006-.075.017C.236-.455.216-.439.2-.419c-.017.02-.029.044-.038.072-.009.028-.014.059-.014.093V0H.08Z"
style="fill: #f8b5cb; fill-rule: nonzero"
transform="translate(32.92220721 521.8022953) scale(235.3092)"
/>
<path
d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(177.16674681 521.8022953) scale(235.3092)"
/>
<path
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(327.76463481 521.8022953) scale(235.3092)"
/>
<path
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.302h.068V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.011-.027.017-.056.017-.089 0-.031-.005-.059-.016-.086C.514-.375.5-.398.481-.417.462-.436.439-.452.414-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(488.71612761 521.8022953) scale(235.3092)"
/>
<path
d="m.034-.062.043-.049c.017.019.035.034.054.044.018.01.037.015.057.015.013 0 .026-.002.038-.007.011-.004.021-.01.031-.018.009-.008.016-.017.021-.028.005-.011.008-.022.008-.035 0-.019-.005-.034-.014-.047C.263-.199.248-.21.229-.221.205-.234.183-.247.162-.259.14-.271.122-.284.107-.298.092-.311.08-.327.071-.344.062-.361.058-.381.058-.404c0-.021.004-.04.012-.058.007-.016.018-.031.031-.044.013-.013.028-.022.046-.029.018-.007.037-.01.057-.01.029 0 .056.006.079.019s.045.031.068.053l-.044.045C.291-.443.275-.456.258-.465.241-.474.221-.479.2-.479c-.022 0-.041.007-.056.02C.128-.445.12-.428.12-.408c0 .019.006.035.017.048.011.013.027.026.048.037.027.015.05.028.071.04.021.013.038.026.052.039.014.013.025.028.032.044.007.016.011.035.011.057 0 .021-.004.041-.011.059-.008.019-.019.036-.033.05-.014.015-.031.026-.05.035C.237.01.215.014.191.014c-.03 0-.059-.006-.086-.02C.077-.019.053-.037.034-.062Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(649.90292961 521.8022953) scale(235.3092)"
/>
<path
d="M.051-.266c0-.04.007-.077.022-.111.014-.034.034-.063.059-.089.025-.025.054-.044.089-.058.035-.014.072-.021.113-.021.051 0 .098.01.139.03.041.021.075.049.1.085l-.05.043C.498-.418.47-.441.439-.456.408-.471.372-.479.331-.479c-.03 0-.058.005-.083.016C.222-.452.2-.436.181-.418.162-.399.148-.376.137-.35c-.011.026-.016.054-.016.084 0 .031.005.06.016.086.011.027.025.049.044.068.019.019.041.034.067.044.025.011.053.016.084.016.077 0 .141-.03.191-.09l.051.04c-.028.036-.062.064-.103.085C.43.004.384.014.332.014.291.014.254.007.219-.008.184-.022.155-.042.13-.067.105-.092.086-.121.072-.156.058-.19.051-.227.051-.266Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(741.20289921 521.8022953) scale(235.3092)"
/>
<path
d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(884.27089281 521.8022953) scale(235.3092)"
/>
<path
d="M.066-.736h.068V0H.066z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(1045.22238561 521.8022953) scale(235.3092)"
/>
<path
d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
style="fill: #8d8d8d; fill-rule: nonzero"
transform="translate(1092.28422561 521.8022953) scale(235.3092)"
/>
<circle
cx="141.023"
cy="338.36"
r="117.472"
style="fill: #f8b5cb"
transform="matrix(.581302 0 0 .58613 40.06479894 12.59842153)"
/>
<circle
cx="352.014"
cy="268.302"
r="33.095"
style="fill: #a2a2a2"
transform="matrix(.59308 0 0 .58289 32.39345942 21.2386)"
/>
<circle
cx="352.014"
cy="268.302"
r="33.095"
style="fill: #a2a2a2"
transform="matrix(.59308 0 0 .58289 32.39345942 88.80371146)"
/>
<circle
cx="352.014"
cy="268.302"
r="33.095"
style="fill: #a2a2a2"
transform="matrix(.59308 0 0 .58289 120.7528627 88.80371146)"
/>
<circle
cx="352.014"
cy="268.302"
r="33.095"
style="fill: #a2a2a2"
transform="matrix(.59308 0 0 .58289 120.99825939 21.2386)"
/>
<circle
cx="805.557"
cy="336.915"
r="118.199"
style="fill: #8d8d8d"
transform="matrix(.5782 0 0 .58289 36.19871106 15.26642564)"
/>
<circle
cx="805.557"
cy="336.915"
r="118.199"
style="fill: #8d8d8d"
transform="matrix(.5782 0 0 .58289 183.24041937 15.26642564)"
/>
<path
d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
style="fill: #303030"
transform="translate(34.2345 21.2386) scale(.58289)"
/>
<path
d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
style="fill: #303030"
transform="matrix(-.58289 0 0 .58289 1116.7719791 21.2386)"
/>
</svg>
<div class="message is-success">
<svg
id="checkbox"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 512 512"
>
<path
d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"
></path>
</svg>
<div class="message-content">
<div class="message-title">Signed in via your OIDC provider</div>
<p class="message-body">
{{.Verb}} as {{.User}}, you can now close this window.
</p>
</div>
</div>
<hr />
<h1>Not sure how to get started?</h1>
<p class="learn">
Check out beginner and advanced guides on, or read more in the
documentation.
</p>
<a
href="https://github.com/juanfont/headscale/tree/main/docs"
rel="noreferrer noopener"
target="_blank"
>
<span class="icon">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
/>
</svg>
</span>
View the headscale documentation
</a>
<a
href="https://tailscale.com/kb/"
rel="noreferrer noopener"
target="_blank"
>
<span class="icon">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
/>
</svg>
</span>
View the tailscale documentation
</a>
</div>
</div>
</body>
</html>

View File

@ -4,10 +4,8 @@ import (
"bytes"
"cmp"
"context"
_ "embed"
"errors"
"fmt"
"html/template"
"net/http"
"slices"
"strings"
@ -16,6 +14,7 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
"github.com/juanfont/headscale/hscontrol/util"
@ -191,13 +190,6 @@ type oidcCallbackTemplateConfig struct {
Verb string
}
//go:embed assets/oidc_callback_template.html
var oidcCallbackTemplateContent string
var oidcCallbackTemplate = template.Must(
template.New("oidccallback").Parse(oidcCallbackTemplateContent),
)
// OIDCCallbackHandler handles the callback from the OIDC endpoint
// Retrieves the nkey from the state cache and adds the node to the users email user
// TODO: A confirmation page for new nodes should be added to avoid phishing vulnerabilities
@ -567,21 +559,12 @@ func (a *AuthProviderOIDC) handleRegistration(
return !nodeChange.Empty(), nil
}
// TODO(kradalby):
// Rewrite in elem-go.
func renderOIDCCallbackTemplate(
user *types.User,
verb string,
) (*bytes.Buffer, error) {
var content bytes.Buffer
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
User: user.Display(),
Verb: verb,
}); err != nil {
return nil, fmt.Errorf("rendering OIDC callback template: %w", err)
}
return &content, nil
html := templates.OIDCCallback(user.Display(), verb).Render()
return bytes.NewBufferString(html), nil
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) {

View File

@ -0,0 +1,67 @@
package hscontrol
import (
"os"
"path/filepath"
"testing"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOIDCCallbackTemplate(t *testing.T) {
tests := []struct {
name string
userName string
verb string
}{
{
name: "logged_in_user",
userName: "test@example.com",
verb: "Logged in",
},
{
name: "registered_user",
userName: "newuser@example.com",
verb: "Registered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Render using the elem-go template
html := templates.OIDCCallback(tt.userName, tt.verb).Render()
// Verify the HTML contains expected elements
assert.Contains(t, html, "<!DOCTYPE html>")
assert.Contains(t, html, "<title>Headscale Authentication Succeeded</title>")
assert.Contains(t, html, tt.verb)
assert.Contains(t, html, tt.userName)
assert.Contains(t, html, "Signed in via your OIDC provider")
assert.Contains(t, html, "you can now close this window")
assert.Contains(t, html, "View the headscale documentation")
assert.Contains(t, html, "View the tailscale documentation")
// Verify CSS styles are present
assert.Contains(t, html, "font-family:")
assert.Contains(t, html, "system-ui")
assert.Contains(t, html, ".container")
assert.Contains(t, html, ".message")
// Verify SVG elements are present
assert.Contains(t, html, "<svg")
assert.Contains(t, html, "id=\"logo\"")
assert.Contains(t, html, "id=\"checkbox\"")
// Save the output for manual inspection
testDataDir := filepath.Join("testdata", "oidc_templates")
err := os.MkdirAll(testDataDir, 0o755)
require.NoError(t, err)
outputFile := filepath.Join(testDataDir, tt.name+".html")
err = os.WriteFile(outputFile, []byte(html), 0o644)
require.NoError(t, err)
})
}
}

View File

@ -5,48 +5,43 @@ import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
func Apple(url string) *elem.Element {
return HtmlStructure(
elem.Title(nil,
elem.Text("headscale - Apple")),
elem.Body(attrs.Props{
attrs.Style: bodyStyle.ToInline(),
},
headerOne("headscale: iOS configuration"),
headerTwo("GUI"),
elem.Ol(nil,
mdTypesetBody(
headscaleLogo(),
H1(elem.Text("iOS configuration")),
H2(elem.Text("GUI")),
Ol(
elem.Li(
nil,
elem.Text("Install the official Tailscale iOS client from the "),
elem.A(
attrs.Props{
attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037",
},
elem.Text("App store"),
),
externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"),
),
elem.Li(
nil,
elem.Text("Open the Tailscale app"),
elem.Text("Open the "),
elem.Strong(nil, elem.Text("Tailscale")),
elem.Text(" app"),
),
elem.Li(
nil,
elem.Text(`Click the account icon in the top-right corner and select "Log in…".`),
elem.Text("Click the account icon in the top-right corner and select "),
elem.Strong(nil, elem.Text("Log in…")),
),
elem.Li(
nil,
elem.Text(`Tap the top-right options menu button and select "Use custom coordination server".`),
elem.Text("Tap the top-right options menu button and select "),
elem.Strong(nil, elem.Text("Use custom coordination server")),
),
elem.Li(
nil,
elem.Text(
fmt.Sprintf(
`Enter your instance URL: "%s"`,
url,
),
),
elem.Text("Enter your instance URL: "),
Code(elem.Text(url)),
),
elem.Li(
nil,
@ -55,65 +50,50 @@ func Apple(url string) *elem.Element {
),
),
),
headerOne("headscale: macOS configuration"),
headerTwo("Command line"),
elem.P(nil,
H1(elem.Text("macOS configuration")),
H2(elem.Text("Command line")),
P(
elem.Text("Use Tailscale's login command to add your profile:"),
),
elem.Pre(nil,
elem.Code(nil,
elem.Text("tailscale login --login-server "+url),
),
),
headerTwo("GUI"),
elem.Ol(nil,
Pre(PreCode("tailscale login --login-server "+url)),
H2(elem.Text("GUI")),
Ol(
elem.Li(
nil,
elem.Text(
"Option + Click the Tailscale icon in the menu and hover over the Debug menu",
),
elem.Text("Option + Click the "),
elem.Strong(nil, elem.Text("Tailscale")),
elem.Text(" icon in the menu and hover over the "),
elem.Strong(nil, elem.Text("Debug")),
elem.Text(" menu"),
),
elem.Li(nil,
elem.Text(`Under "Custom Login Server", select "Add Account..."`),
elem.Text("Under "),
elem.Strong(nil, elem.Text("Custom Login Server")),
elem.Text(", select "),
elem.Strong(nil, elem.Text("Add Account...")),
),
elem.Li(
nil,
elem.Text(
fmt.Sprintf(
`Enter "%s" of the headscale instance and press "Add Account"`,
url,
),
),
elem.Text("Enter "),
Code(elem.Text(url)),
elem.Text(" of the headscale instance and press "),
elem.Strong(nil, elem.Text("Add Account")),
),
elem.Li(nil,
elem.Text(`Follow the login procedure in the browser`),
elem.Text("Follow the login procedure in the browser"),
),
),
headerTwo("Profiles"),
elem.P(
nil,
H2(elem.Text("Profiles")),
P(
elem.Text(
"Headscale can be set to the default server by installing a Headscale configuration profile:",
),
),
elem.P(
nil,
elem.A(
attrs.Props{
attrs.Href: "/apple/macos-app-store",
attrs.Download: "headscale_macos.mobileconfig",
},
elem.Text("macOS AppStore profile "),
),
elem.A(
attrs.Props{
attrs.Href: "/apple/macos-standalone",
attrs.Download: "headscale_macos.mobileconfig",
},
elem.Text("macOS Standalone profile"),
),
elem.Div(attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()},
downloadButton("/apple/macos-app-store", "macOS AppStore profile"),
downloadButton("/apple/macos-standalone", "macOS Standalone profile"),
),
elem.Ol(nil,
Ol(
elem.Li(
nil,
elem.Text(
@ -121,105 +101,82 @@ func Apple(url string) *elem.Element {
),
),
elem.Li(nil,
elem.Text(`Open System Preferences and go to "Profiles"`),
elem.Text("Open "),
elem.Strong(nil, elem.Text("System Preferences")),
elem.Text(" and go to "),
elem.Strong(nil, elem.Text("Profiles")),
),
elem.Li(nil,
elem.Text(`Find and install the Headscale profile`),
elem.Text("Find and install the "),
elem.Strong(nil, elem.Text("Headscale")),
elem.Text(" profile"),
),
elem.Li(nil,
elem.Text(`Restart Tailscale.app and log in`),
elem.Text("Restart "),
elem.Strong(nil, elem.Text("Tailscale.app")),
elem.Text(" and log in"),
),
),
elem.P(nil, elem.Text("Or")),
elem.P(
nil,
orDivider(),
P(
elem.Text(
"Use your terminal to configure the default setting for Tailscale by issuing:",
"Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:",
),
),
elem.Ul(nil,
elem.Li(nil,
elem.Text(`for app store client:`),
elem.Code(
nil,
elem.Text(
"defaults write io.tailscale.ipn.macos ControlURL "+url,
),
),
),
elem.Li(nil,
elem.Text(`for standalone client:`),
elem.Code(
nil,
elem.Text(
"defaults write io.tailscale.ipn.macsys ControlURL "+url,
),
),
),
P(elem.Text("For app store client:")),
Pre(PreCode("defaults write io.tailscale.ipn.macos ControlURL "+url)),
P(elem.Text("For standalone client:")),
Pre(PreCode("defaults write io.tailscale.ipn.macsys ControlURL "+url)),
P(
elem.Text("Restart "),
elem.Strong(nil, elem.Text("Tailscale.app")),
elem.Text(" and log in."),
),
elem.P(nil,
elem.Text("Restart Tailscale.app and log in."),
),
headerThree("Caution"),
elem.P(
nil,
elem.Text(
"You should always download and inspect the profile before installing it:",
),
),
elem.Ul(nil,
elem.Li(nil,
elem.Text(`for app store client: `),
elem.Code(nil,
elem.Text(fmt.Sprintf(`curl %s/apple/macos-app-store`, url)),
),
),
elem.Li(nil,
elem.Text(`for standalone client: `),
elem.Code(nil,
elem.Text(fmt.Sprintf(`curl %s/apple/macos-standalone`, url)),
),
),
),
headerOne("headscale: tvOS configuration"),
headerTwo("GUI"),
elem.Ol(nil,
warningBox("Caution", "You should always download and inspect the profile before installing it."),
P(elem.Text("For app store client:")),
Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-app-store`, url))),
P(elem.Text("For standalone client:")),
Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-standalone`, url))),
H1(elem.Text("tvOS configuration")),
H2(elem.Text("GUI")),
Ol(
elem.Li(
nil,
elem.Text("Install the official Tailscale tvOS client from the "),
elem.A(
attrs.Props{
attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037",
},
elem.Text("App store"),
),
externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"),
),
elem.Li(
nil,
elem.Text(
"Open Settings (the Apple tvOS settings) > Apps > Tailscale",
),
elem.Text("Open "),
elem.Strong(nil, elem.Text("Settings")),
elem.Text(" (the Apple tvOS settings) > "),
elem.Strong(nil, elem.Text("Apps")),
elem.Text(" > "),
elem.Strong(nil, elem.Text("Tailscale")),
),
elem.Li(
nil,
elem.Text(
fmt.Sprintf(
`Enter "%s" under "ALTERNATE COORDINATION SERVER URL"`,
url,
),
),
elem.Text("Enter "),
Code(elem.Text(url)),
elem.Text(" under "),
elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")),
),
elem.Li(nil,
elem.Text("Return to the tvOS Home screen"),
elem.Text("Return to the tvOS "),
elem.Strong(nil, elem.Text("Home")),
elem.Text(" screen"),
),
elem.Li(nil,
elem.Text("Open Tailscale"),
elem.Text("Open "),
elem.Strong(nil, elem.Text("Tailscale")),
),
elem.Li(nil,
elem.Text(`Select "Install VPN configuration"`),
elem.Text("Select "),
elem.Strong(nil, elem.Text("Install VPN configuration")),
),
elem.Li(nil,
elem.Text(`Select "Allow"`),
elem.Text("Select "),
elem.Strong(nil, elem.Text("Allow")),
),
elem.Li(nil,
elem.Text("Scan the QR code and follow the login procedure"),
@ -228,6 +185,7 @@ func Apple(url string) *elem.Element {
elem.Text("Headscale should now be working on your tvOS device"),
),
),
pageFooter(),
),
)
}

View File

@ -0,0 +1,482 @@
package templates
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
// Design System Constants
// These constants define the visual language for all Headscale HTML templates.
// They ensure consistency across all pages and make it easy to maintain and update the design.
// Color System
// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css
// Material for MkDocs design system - exact values from official docs.
const (
// Text colors - from --md-default-fg-color CSS variables
colorTextPrimary = "#000000de" //nolint:unused // rgba(0,0,0,0.87) - Body text
colorTextSecondary = "#0000008a" //nolint:unused // rgba(0,0,0,0.54) - Headings (--md-default-fg-color--light)
colorTextTertiary = "#00000052" //nolint:unused // rgba(0,0,0,0.32) - Lighter text
colorTextLightest = "#00000012" //nolint:unused // rgba(0,0,0,0.07) - Lightest text
// Code colors - from --md-code-* CSS variables
colorCodeFg = "#36464e" //nolint:unused // Code text color (--md-code-fg-color)
colorCodeBg = "#f5f5f5" //nolint:unused // Code background (--md-code-bg-color)
// Border colors
colorBorderLight = "#e5e7eb" //nolint:unused // Light borders
colorBorderMedium = "#d1d5db" //nolint:unused // Medium borders
// Background colors
colorBackgroundPage = "#ffffff" //nolint:unused // Page background
colorBackgroundCard = "#ffffff" //nolint:unused // Card/content background
// Accent colors - from --md-primary/accent-fg-color
colorPrimaryAccent = "#4051b5" //nolint:unused // Primary accent (links)
colorAccent = "#526cfe" //nolint:unused // Secondary accent
// Success colors
colorSuccess = "#059669" //nolint:unused // Success states
colorSuccessLight = "#d1fae5" //nolint:unused // Success backgrounds
)
// Spacing System
// Based on 4px/8px base unit for consistent rhythm.
// Uses rem units for scalability with user font size preferences.
const (
spaceXS = "0.25rem" //nolint:unused // 4px - Tight spacing
spaceS = "0.5rem" //nolint:unused // 8px - Small spacing
spaceM = "1rem" //nolint:unused // 16px - Medium spacing (base)
spaceL = "1.5rem" //nolint:unused // 24px - Large spacing
spaceXL = "2rem" //nolint:unused // 32px - Extra large spacing
space2XL = "3rem" //nolint:unused // 48px - 2x extra large spacing
space3XL = "4rem" //nolint:unused // 64px - 3x extra large spacing
)
// Typography System
// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css
// Material for MkDocs typography - exact values from .md-typeset CSS.
const (
// Font families - from CSS custom properties
fontFamilySystem = `"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif` //nolint:unused
fontFamilyCode = `"Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace` //nolint:unused
// Font sizes - from .md-typeset CSS rules
fontSizeBase = "0.8rem" //nolint:unused // 12.8px - Base text (.md-typeset)
fontSizeH1 = "2em" //nolint:unused // 2x base - Main headings
fontSizeH2 = "1.5625em" //nolint:unused // 1.5625x base - Section headings
fontSizeH3 = "1.25em" //nolint:unused // 1.25x base - Subsection headings
fontSizeSmall = "0.8em" //nolint:unused // 0.8x base - Small text
fontSizeCode = "0.85em" //nolint:unused // 0.85x base - Inline code
// Line heights - from .md-typeset CSS rules
lineHeightBase = "1.6" //nolint:unused // Body text (.md-typeset)
lineHeightH1 = "1.3" //nolint:unused // H1 headings
lineHeightH2 = "1.4" //nolint:unused // H2 headings
lineHeightH3 = "1.5" //nolint:unused // H3 headings
lineHeightCode = "1.4" //nolint:unused // Code blocks (pre)
)
// Responsive Container Component
// Creates a centered container with responsive padding and max-width.
// Mobile-first approach: starts at 100% width with padding, constrains on larger screens.
//
//nolint:unused // Reserved for future use in Phase 4.
func responsiveContainer(children ...elem.Node) *elem.Element {
return elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Width: "100%",
styles.MaxWidth: "min(800px, 90vw)", // Responsive: 90% of viewport or 800px max
styles.Margin: "0 auto", // Center horizontally
styles.Padding: "clamp(1rem, 5vw, 2.5rem)", // Fluid padding: 16px to 40px
}.ToInline(),
}, children...)
}
// Card Component
// Reusable card for grouping related content with visual separation.
// Parameters:
// - title: Optional title for the card (empty string for no title)
// - children: Content elements to display in the card
//
//nolint:unused // Reserved for future use in Phase 4.
func card(title string, children ...elem.Node) *elem.Element {
cardContent := children
if title != "" {
// Prepend title as H3 if provided
cardContent = append([]elem.Node{
elem.H3(attrs.Props{
attrs.Style: styles.Props{
styles.MarginTop: "0",
styles.MarginBottom: spaceM,
styles.FontSize: fontSizeH3,
styles.LineHeight: lineHeightH3, // 1.5 - H3 line height
styles.Color: colorTextSecondary,
}.ToInline(),
}, elem.Text(title)),
}, children...)
}
return elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Background: colorBackgroundCard,
styles.Border: "1px solid " + colorBorderLight,
styles.BorderRadius: "0.5rem", // 8px rounded corners
styles.Padding: "clamp(1rem, 3vw, 1.5rem)", // Responsive padding
styles.MarginBottom: spaceL,
styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)", // Subtle shadow
}.ToInline(),
}, cardContent...)
}
// Code Block Component
// EXTRACTED FROM: .md-typeset pre CSS rules
// Exact styling from Material for MkDocs documentation.
//
//nolint:unused // Used across apple.go, windows.go, register_web.go templates.
func codeBlock(code string) *elem.Element {
return elem.Pre(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "block",
styles.Padding: "0.77em 1.18em", // From .md-typeset pre
styles.Border: "none", // No border in original
styles.BorderRadius: "0.1rem", // From .md-typeset code
styles.BackgroundColor: colorCodeBg, // #f5f5f5
styles.FontFamily: fontFamilyCode, // Roboto Mono
styles.FontSize: fontSizeCode, // 0.85em
styles.LineHeight: lineHeightCode, // 1.4
styles.OverflowX: "auto", // Horizontal scroll
"overflow-wrap": "break-word", // Word wrapping
"word-wrap": "break-word", // Legacy support
styles.WhiteSpace: "pre-wrap", // Preserve whitespace
styles.MarginTop: spaceM, // 1em
styles.MarginBottom: spaceM, // 1em
styles.Color: colorCodeFg, // #36464e
styles.BoxShadow: "none", // No shadow in original
}.ToInline(),
},
elem.Code(nil, elem.Text(code)),
)
}
// Base Typeset Styles
// Returns inline styles for the main content container that matches .md-typeset.
// EXTRACTED FROM: .md-typeset CSS rule from Material for MkDocs.
//
//nolint:unused // Used in general.go for mdTypesetBody.
func baseTypesetStyles() styles.Props {
return styles.Props{
styles.FontSize: fontSizeBase, // 0.8rem
styles.LineHeight: lineHeightBase, // 1.6
styles.Color: colorTextPrimary,
styles.FontFamily: fontFamilySystem,
"overflow-wrap": "break-word",
styles.TextAlign: "left",
}
}
// H1 Styles
// Returns inline styles for H1 headings that match .md-typeset h1.
// EXTRACTED FROM: .md-typeset h1 CSS rule from Material for MkDocs.
//
//nolint:unused // Used across templates for main headings.
func h1Styles() styles.Props {
return styles.Props{
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
styles.FontSize: fontSizeH1, // 2em
styles.LineHeight: lineHeightH1, // 1.3
styles.Margin: "0 0 1.25em",
styles.FontWeight: "300",
"letter-spacing": "-0.01em",
styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word",
}
}
// H2 Styles
// Returns inline styles for H2 headings that match .md-typeset h2.
// EXTRACTED FROM: .md-typeset h2 CSS rule from Material for MkDocs.
//
//nolint:unused // Used across templates for section headings.
func h2Styles() styles.Props {
return styles.Props{
styles.FontSize: fontSizeH2, // 1.5625em
styles.LineHeight: lineHeightH2, // 1.4
styles.Margin: "1.6em 0 0.64em",
styles.FontWeight: "300",
"letter-spacing": "-0.01em",
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word",
}
}
// H3 Styles
// Returns inline styles for H3 headings that match .md-typeset h3.
// EXTRACTED FROM: .md-typeset h3 CSS rule from Material for MkDocs.
//
//nolint:unused // Used across templates for subsection headings.
func h3Styles() styles.Props {
return styles.Props{
styles.FontSize: fontSizeH3, // 1.25em
styles.LineHeight: lineHeightH3, // 1.5
styles.Margin: "1.6em 0 0.8em",
styles.FontWeight: "400",
"letter-spacing": "-0.01em",
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word",
}
}
// Paragraph Styles
// Returns inline styles for paragraphs that match .md-typeset p.
// EXTRACTED FROM: .md-typeset p CSS rule from Material for MkDocs.
//
//nolint:unused // Used for consistent paragraph spacing.
func paragraphStyles() styles.Props {
return styles.Props{
styles.Margin: "1em 0",
styles.FontFamily: fontFamilySystem, // Roboto
styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset
styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87)
"overflow-wrap": "break-word",
}
}
// Ordered List Styles
// Returns inline styles for ordered lists that match .md-typeset ol.
// EXTRACTED FROM: .md-typeset ol CSS rule from Material for MkDocs.
//
//nolint:unused // Used for numbered instruction lists.
func orderedListStyles() styles.Props {
return styles.Props{
styles.MarginBottom: "1em",
styles.MarginTop: "1em",
styles.PaddingLeft: "2em",
styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset
styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset
styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset
"overflow-wrap": "break-word",
}
}
// Unordered List Styles
// Returns inline styles for unordered lists that match .md-typeset ul.
// EXTRACTED FROM: .md-typeset ul CSS rule from Material for MkDocs.
//
//nolint:unused // Used for bullet point lists.
func unorderedListStyles() styles.Props {
return styles.Props{
styles.MarginBottom: "1em",
styles.MarginTop: "1em",
styles.PaddingLeft: "2em",
styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset
styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset
styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset
"overflow-wrap": "break-word",
}
}
// Link Styles
// Returns inline styles for links that match .md-typeset a.
// EXTRACTED FROM: .md-typeset a CSS rule from Material for MkDocs.
// Note: Hover states cannot be implemented with inline styles.
//
//nolint:unused // Used for text links.
func linkStyles() styles.Props {
return styles.Props{
styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color)
styles.TextDecoration: "none",
"word-break": "break-word",
styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset
}
}
// Inline Code Styles (updated)
// Returns inline styles for inline code that matches .md-typeset code.
// EXTRACTED FROM: .md-typeset code CSS rule from Material for MkDocs.
//
//nolint:unused // Used for inline code snippets.
func inlineCodeStyles() styles.Props {
return styles.Props{
styles.BackgroundColor: colorCodeBg, // #f5f5f5
styles.Color: colorCodeFg, // #36464e
styles.BorderRadius: "0.1rem",
styles.FontSize: fontSizeCode, // 0.85em
styles.FontFamily: fontFamilyCode, // Roboto Mono
styles.Padding: "0 0.2941176471em",
"word-break": "break-word",
}
}
// Inline Code Component
// For inline code snippets within text.
//
//nolint:unused // Reserved for future inline code usage.
func inlineCode(code string) *elem.Element {
return elem.Code(attrs.Props{
attrs.Style: inlineCodeStyles().ToInline(),
}, elem.Text(code))
}
// orDivider creates a visual "or" divider between sections.
// Styled with lines on either side for better visual separation.
//
//nolint:unused // Used in apple.go template.
func orDivider() *elem.Element {
return elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.Gap: spaceM,
styles.MarginTop: space2XL,
styles.MarginBottom: space2XL,
styles.Width: "100%",
}.ToInline(),
},
elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Flex: "1",
styles.Height: "1px",
styles.BackgroundColor: colorBorderLight,
}.ToInline(),
}),
elem.Strong(attrs.Props{
attrs.Style: styles.Props{
styles.Color: colorTextSecondary,
styles.FontSize: fontSizeBase,
styles.FontWeight: "500",
"text-transform": "uppercase",
"letter-spacing": "0.05em",
}.ToInline(),
}, elem.Text("or")),
elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Flex: "1",
styles.Height: "1px",
styles.BackgroundColor: colorBorderLight,
}.ToInline(),
}),
)
}
// warningBox creates a warning message box with icon and content.
//
//nolint:unused // Used in apple.go template.
func warningBox(title, message string) *elem.Element {
return elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "flex-start",
styles.Gap: spaceM,
styles.Padding: spaceL,
styles.BackgroundColor: "#fef3c7", // yellow-100
styles.Border: "1px solid #f59e0b", // yellow-500
styles.BorderRadius: "0.5rem",
styles.MarginTop: spaceL,
styles.MarginBottom: spaceL,
}.ToInline(),
},
elem.Raw(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0; margin-top: 2px;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`),
elem.Div(nil,
elem.Strong(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "block",
styles.Color: "#92400e", // yellow-800
styles.FontSize: fontSizeH3,
styles.MarginBottom: spaceXS,
}.ToInline(),
}, elem.Text(title)),
elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Color: colorTextPrimary,
styles.FontSize: fontSizeBase,
}.ToInline(),
}, elem.Text(message)),
),
)
}
// downloadButton creates a nice button-style link for downloads.
//
//nolint:unused // Used in apple.go template.
func downloadButton(href, text string) *elem.Element {
return elem.A(attrs.Props{
attrs.Href: href,
attrs.Download: "headscale_macos.mobileconfig",
attrs.Style: styles.Props{
styles.Display: "inline-block",
styles.Padding: "0.75rem 1.5rem",
styles.BackgroundColor: "#3b82f6", // blue-500
styles.Color: "#ffffff",
styles.TextDecoration: "none",
styles.BorderRadius: "0.5rem",
styles.FontWeight: "500",
styles.Transition: "background-color 0.2s",
styles.MarginRight: spaceM,
styles.MarginBottom: spaceM,
}.ToInline(),
}, elem.Text(text))
}
// External Link Component
// Creates a link with proper security attributes for external URLs.
// Automatically adds rel="noreferrer noopener" and target="_blank".
//
//nolint:unused // Used in apple.go, oidc_callback.go templates.
func externalLink(href, text string) *elem.Element {
return elem.A(attrs.Props{
attrs.Href: href,
attrs.Rel: "noreferrer noopener",
attrs.Target: "_blank",
attrs.Style: styles.Props{
styles.Color: colorPrimaryAccent, // #4051b5 - base link color
styles.TextDecoration: "none",
}.ToInline(),
}, elem.Text(text))
}
// Instruction Step Component
// For numbered instruction lists with consistent formatting.
//
//nolint:unused // Reserved for future use in Phase 4.
func instructionStep(_ int, text string) *elem.Element {
return elem.Li(attrs.Props{
attrs.Style: styles.Props{
styles.MarginBottom: spaceS,
styles.LineHeight: lineHeightBase,
}.ToInline(),
}, elem.Text(text))
}
// Status Message Component
// For displaying success/error/info messages with appropriate styling.
//
//nolint:unused // Reserved for future use in Phase 4.
func statusMessage(message string, isSuccess bool) *elem.Element {
bgColor := colorSuccessLight
textColor := colorSuccess
if !isSuccess {
bgColor = "#fee2e2" // red-100
textColor = "#dc2626" // red-600
}
return elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Padding: spaceM,
styles.BackgroundColor: bgColor,
styles.Color: textColor,
styles.BorderRadius: "0.5rem",
styles.Border: "1px solid " + textColor,
styles.MarginBottom: spaceL,
styles.FontSize: fontSizeBase,
styles.LineHeight: lineHeightBase,
}.ToInline(),
}, elem.Text(message))
}

View File

@ -1,43 +1,179 @@
package templates
import (
_ "embed"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
var bodyStyle = styles.Props{
styles.Margin: "40px auto",
styles.MaxWidth: "800px",
styles.LineHeight: "1.5",
styles.FontSize: "16px",
styles.Color: "#444",
styles.Padding: "0 10px",
styles.FontFamily: "Sans-serif",
//go:embed style.css
var headscaleCSS string
//go:embed headscale.svg
var headscaleSVG string
// mdTypesetBody creates a body element with md-typeset styling
// that matches the official Headscale documentation design.
// Uses CSS classes with styles defined in headscaleCSS.
func mdTypesetBody(children ...elem.Node) *elem.Element {
return elem.Body(attrs.Props{
attrs.Style: styles.Props{
styles.MinHeight: "100vh",
styles.Display: "flex",
styles.FlexDirection: "column",
styles.AlignItems: "center",
styles.BackgroundColor: "#ffffff",
styles.Padding: "3rem 1.5rem",
}.ToInline(),
"translate": "no",
},
elem.Div(attrs.Props{
attrs.Class: "md-typeset",
attrs.Style: styles.Props{
styles.MaxWidth: "min(800px, 90vw)",
styles.Width: "100%",
}.ToInline(),
}, children...),
)
}
var headerStyle = styles.Props{
styles.LineHeight: "1.2",
// Styled Element Wrappers
// These functions wrap elem-go elements using CSS classes.
// Styling is handled by the CSS in headscaleCSS.
// H1 creates a H1 element styled by .md-typeset h1
func H1(children ...elem.Node) *elem.Element {
return elem.H1(nil, children...)
}
// H2 creates a H2 element styled by .md-typeset h2
func H2(children ...elem.Node) *elem.Element {
return elem.H2(nil, children...)
}
// H3 creates a H3 element styled by .md-typeset h3
func H3(children ...elem.Node) *elem.Element {
return elem.H3(nil, children...)
}
// P creates a paragraph element styled by .md-typeset p
func P(children ...elem.Node) *elem.Element {
return elem.P(nil, children...)
}
// Ol creates an ordered list element styled by .md-typeset ol
func Ol(children ...elem.Node) *elem.Element {
return elem.Ol(nil, children...)
}
// Ul creates an unordered list element styled by .md-typeset ul
func Ul(children ...elem.Node) *elem.Element {
return elem.Ul(nil, children...)
}
// A creates a link element styled by .md-typeset a
func A(href string, children ...elem.Node) *elem.Element {
return elem.A(attrs.Props{attrs.Href: href}, children...)
}
// Code creates an inline code element styled by .md-typeset code
func Code(children ...elem.Node) *elem.Element {
return elem.Code(nil, children...)
}
// Pre creates a preformatted text block styled by .md-typeset pre
func Pre(children ...elem.Node) *elem.Element {
return elem.Pre(nil, children...)
}
// PreCode creates a code block inside Pre styled by .md-typeset pre > code
func PreCode(code string) *elem.Element {
return elem.Code(nil, elem.Text(code))
}
// Deprecated: use H1, H2, H3 instead
func headerOne(text string) *elem.Element {
return elem.H1(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
return H1(elem.Text(text))
}
// Deprecated: use H1, H2, H3 instead
func headerTwo(text string) *elem.Element {
return elem.H2(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
return H2(elem.Text(text))
}
// Deprecated: use H1, H2, H3 instead
func headerThree(text string) *elem.Element {
return elem.H3(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
return H3(elem.Text(text))
}
// contentContainer wraps page content with proper width.
// Content inside is left-aligned by default.
func contentContainer(children ...elem.Node) *elem.Element {
containerStyle := styles.Props{
styles.MaxWidth: "720px",
styles.Width: "100%",
styles.Display: "flex",
styles.FlexDirection: "column",
styles.AlignItems: "flex-start", // Left-align all children
}
return elem.Div(attrs.Props{attrs.Style: containerStyle.ToInline()}, children...)
}
// headscaleLogo returns the Headscale SVG logo for consistent branding across all pages.
// The logo is styled by the .headscale-logo CSS class.
func headscaleLogo() elem.Node {
// Return the embedded SVG as-is
return elem.Raw(headscaleSVG)
}
// pageFooter creates a consistent footer for all pages.
func pageFooter() *elem.Element {
footerStyle := styles.Props{
styles.MarginTop: space3XL,
styles.TextAlign: "center",
styles.FontSize: fontSizeSmall,
styles.Color: colorTextSecondary,
styles.LineHeight: lineHeightBase,
}
linkStyle := styles.Props{
styles.Color: colorTextSecondary,
styles.TextDecoration: "underline",
}
return elem.Div(attrs.Props{attrs.Style: footerStyle.ToInline()},
elem.Text("Powered by "),
elem.A(attrs.Props{
attrs.Href: "https://github.com/juanfont/headscale",
attrs.Rel: "noreferrer noopener",
attrs.Target: "_blank",
attrs.Style: linkStyle.ToInline(),
}, elem.Text("Headscale")),
)
}
// listStyle provides consistent styling for ordered and unordered lists
// EXTRACTED FROM: .md-typeset ol, .md-typeset ul CSS rules
var listStyle = styles.Props{
styles.LineHeight: lineHeightBase, // 1.6 - From .md-typeset
styles.MarginTop: "1em", // From CSS: margin-top: 1em
styles.MarginBottom: "1em", // From CSS: margin-bottom: 1em
styles.PaddingLeft: "clamp(1.5rem, 5vw, 2.5rem)", // Responsive indentation
}
// HtmlStructure creates a complete HTML document structure with proper meta tags
// and semantic HTML5 structure. The head and body elements are passed as parameters
// to allow for customization of each page.
// Styling is provided via a CSS stylesheet (Material for MkDocs design system) with
// minimal inline styles for layout and positioning.
func HtmlStructure(head, body *elem.Element) *elem.Element {
return elem.Html(nil,
elem.Head(
attrs.Props{
attrs.Lang: "en",
},
return elem.Html(attrs.Props{attrs.Lang: "en"},
elem.Head(nil,
elem.Meta(attrs.Props{
attrs.Charset: "UTF-8",
}),
@ -49,6 +185,18 @@ func HtmlStructure(head, body *elem.Element) *elem.Element {
attrs.Name: "viewport",
attrs.Content: "width=device-width, initial-scale=1.0",
}),
// Google Fonts for Roboto and Roboto Mono
elem.Link(attrs.Props{
attrs.Rel: "preconnect",
attrs.Href: "https://fonts.gstatic.com",
"crossorigin": "",
}),
elem.Link(attrs.Props{
attrs.Rel: "stylesheet",
attrs.Href: "https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;700&display=swap",
}),
// Material for MkDocs CSS styles
elem.Style(attrs.Props{attrs.Type: "text/css"}, elem.Raw(headscaleCSS)),
head,
),
body,

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -0,0 +1,69 @@
package templates
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
// checkboxIcon returns the success checkbox SVG icon as raw HTML.
func checkboxIcon() elem.Node {
return elem.Raw(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512">
<path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
</svg>`)
}
// OIDCCallback renders the OIDC authentication success callback page.
func OIDCCallback(user, verb string) *elem.Element {
// Success message box
successBox := elem.Div(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.Gap: spaceM,
styles.Padding: spaceL,
styles.BackgroundColor: colorSuccessLight,
styles.Border: "1px solid " + colorSuccess,
styles.BorderRadius: "0.5rem",
styles.MarginBottom: spaceXL,
}.ToInline(),
},
checkboxIcon(),
elem.Div(nil,
elem.Strong(attrs.Props{
attrs.Style: styles.Props{
styles.Display: "block",
styles.Color: colorSuccess,
styles.FontSize: fontSizeH3,
styles.MarginBottom: spaceXS,
}.ToInline(),
}, elem.Text("Signed in successfully")),
elem.P(attrs.Props{
attrs.Style: styles.Props{
styles.Margin: "0",
styles.Color: colorTextPrimary,
styles.FontSize: fontSizeBase,
}.ToInline(),
}, elem.Text(verb), elem.Text(" as "), elem.Strong(nil, elem.Text(user)), elem.Text(". You can now close this window.")),
),
)
return HtmlStructure(
elem.Title(nil, elem.Text("Headscale Authentication Succeeded")),
mdTypesetBody(
headscaleLogo(),
successBox,
H2(elem.Text("Getting started")),
P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")),
Ul(
elem.Li(nil,
externalLink("https://github.com/juanfont/headscale/tree/main/docs", "Headscale documentation"),
),
elem.Li(nil,
externalLink("https://tailscale.com/kb/", "Tailscale knowledge base"),
),
),
pageFooter(),
),
)
}

View File

@ -4,32 +4,18 @@ import (
"fmt"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
"github.com/juanfont/headscale/hscontrol/types"
)
var codeStyleRegisterWebAPI = styles.Props{
styles.Display: "block",
styles.Padding: "20px",
styles.Border: "1px solid #bbb",
styles.BackgroundColor: "#eee",
}
func RegisterWeb(registrationID types.RegistrationID) *elem.Element {
return HtmlStructure(
elem.Title(nil, elem.Text("Registration - Headscale")),
elem.Body(attrs.Props{
attrs.Style: styles.Props{
styles.FontFamily: "sans",
}.ToInline(),
},
elem.H1(nil, elem.Text("headscale")),
elem.H2(nil, elem.Text("Machine registration")),
elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network: ")),
elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()},
elem.Text(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String())),
),
mdTypesetBody(
headscaleLogo(),
H1(elem.Text("Machine registration")),
P(elem.Text("Run the command below in the headscale server to add this machine to your network:")),
Pre(PreCode(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String()))),
pageFooter(),
),
)
}

View File

@ -0,0 +1,143 @@
/* CSS Variables from Material for MkDocs */
:root {
--md-default-fg-color: rgba(0, 0, 0, 0.87);
--md-default-fg-color--light: rgba(0, 0, 0, 0.54);
--md-default-fg-color--lighter: rgba(0, 0, 0, 0.32);
--md-default-fg-color--lightest: rgba(0, 0, 0, 0.07);
--md-code-fg-color: #36464e;
--md-code-bg-color: #f5f5f5;
--md-primary-fg-color: #4051b5;
--md-accent-fg-color: #526cfe;
--md-typeset-a-color: var(--md-primary-fg-color);
--md-text-font: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--md-code-font: "Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace;
}
/* Base Typography */
.md-typeset {
font-size: 0.8rem;
line-height: 1.6;
color: var(--md-default-fg-color);
font-family: var(--md-text-font);
overflow-wrap: break-word;
text-align: left;
}
/* Headings */
.md-typeset h1 {
color: var(--md-default-fg-color--light);
font-size: 2em;
line-height: 1.3;
margin: 0 0 1.25em;
font-weight: 300;
letter-spacing: -0.01em;
}
.md-typeset h1:not(:first-child) {
margin-top: 2em;
}
.md-typeset h2 {
font-size: 1.5625em;
line-height: 1.4;
margin: 2.4em 0 0.64em;
font-weight: 300;
letter-spacing: -0.01em;
color: var(--md-default-fg-color--light);
}
.md-typeset h3 {
font-size: 1.25em;
line-height: 1.5;
margin: 2em 0 0.8em;
font-weight: 400;
letter-spacing: -0.01em;
color: var(--md-default-fg-color--light);
}
/* Paragraphs and block elements */
.md-typeset p {
margin: 1em 0;
}
.md-typeset blockquote,
.md-typeset dl,
.md-typeset figure,
.md-typeset ol,
.md-typeset pre,
.md-typeset ul {
margin-bottom: 1em;
margin-top: 1em;
}
/* Lists */
.md-typeset ol,
.md-typeset ul {
padding-left: 2em;
}
/* Links */
.md-typeset a {
color: var(--md-typeset-a-color);
text-decoration: none;
word-break: break-word;
}
.md-typeset a:hover,
.md-typeset a:focus {
color: var(--md-accent-fg-color);
}
/* Code (inline) */
.md-typeset code {
background-color: var(--md-code-bg-color);
color: var(--md-code-fg-color);
border-radius: 0.1rem;
font-size: 0.85em;
font-family: var(--md-code-font);
padding: 0 0.2941176471em;
word-break: break-word;
}
/* Code blocks (pre) */
.md-typeset pre {
display: block;
line-height: 1.4;
margin: 1em 0;
overflow-x: auto;
}
.md-typeset pre > code {
background-color: var(--md-code-bg-color);
color: var(--md-code-fg-color);
display: block;
padding: 0.7720588235em 1.1764705882em;
font-family: var(--md-code-font);
font-size: 0.85em;
line-height: 1.4;
overflow-wrap: break-word;
word-wrap: break-word;
white-space: pre-wrap;
}
/* Links in code */
.md-typeset a code {
color: currentcolor;
}
/* Logo */
.headscale-logo {
display: block;
width: 400px;
max-width: 100%;
height: auto;
margin: 0 0 3rem 0;
padding: 0;
}
@media (max-width: 768px) {
.headscale-logo {
width: 200px;
margin-left: 0;
}
}

View File

@ -2,7 +2,6 @@ package templates
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
)
func Windows(url string) *elem.Element {
@ -10,28 +9,19 @@ func Windows(url string) *elem.Element {
elem.Title(nil,
elem.Text("headscale - Windows"),
),
elem.Body(attrs.Props{
attrs.Style: bodyStyle.ToInline(),
},
headerOne("headscale: Windows configuration"),
elem.P(nil,
mdTypesetBody(
headscaleLogo(),
H1(elem.Text("Windows configuration")),
P(
elem.Text("Download "),
elem.A(attrs.Props{
attrs.Href: "https://tailscale.com/download/windows",
attrs.Rel: "noreferrer noopener",
attrs.Target: "_blank",
},
elem.Text("Tailscale for Windows ")),
elem.Text("and install it."),
externalLink("https://tailscale.com/download/windows", "Tailscale for Windows"),
elem.Text(" and install it."),
),
elem.P(nil,
elem.Text("Open a Command Prompt or Powershell and use Tailscale's login command to connect with headscale: "),
),
elem.Pre(nil,
elem.Code(nil,
elem.Text("tailscale login --login-server "+url),
),
P(
elem.Text("Open a Command Prompt or PowerShell and use Tailscale's login command to connect with headscale:"),
),
Pre(PreCode("tailscale login --login-server "+url)),
pageFooter(),
),
)
}

View File

@ -0,0 +1,212 @@
package hscontrol
import (
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
)
func TestTemplateHTMLConsistency(t *testing.T) {
// Test all templates produce consistent modern HTML
testCases := []struct {
name string
html string
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
},
{
name: "Register Web",
html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
},
{
name: "Windows Config",
html: templates.Windows("https://example.com").Render(),
},
{
name: "Apple Config",
html: templates.Apple("https://example.com").Render(),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Check DOCTYPE
assert.True(t, strings.HasPrefix(tc.html, "<!DOCTYPE html>"),
"%s should start with <!DOCTYPE html>", tc.name)
// Check HTML5 lang attribute
assert.Contains(t, tc.html, `<html lang="en">`,
"%s should have html lang=\"en\"", tc.name)
// Check UTF-8 charset
assert.Contains(t, tc.html, `charset="UTF-8"`,
"%s should have UTF-8 charset", tc.name)
// Check viewport meta tag
assert.Contains(t, tc.html, `name="viewport"`,
"%s should have viewport meta tag", tc.name)
// Check IE compatibility meta tag
assert.Contains(t, tc.html, `X-UA-Compatible`,
"%s should have X-UA-Compatible meta tag", tc.name)
// Check closing tags
assert.Contains(t, tc.html, "</html>",
"%s should have closing html tag", tc.name)
assert.Contains(t, tc.html, "</head>",
"%s should have closing head tag", tc.name)
assert.Contains(t, tc.html, "</body>",
"%s should have closing body tag", tc.name)
})
}
}
func TestTemplateModernHTMLFeatures(t *testing.T) {
testCases := []struct {
name string
html string
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
},
{
name: "Register Web",
html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
},
{
name: "Windows Config",
html: templates.Windows("https://example.com").Render(),
},
{
name: "Apple Config",
html: templates.Apple("https://example.com").Render(),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Check no deprecated tags
assert.NotContains(t, tc.html, "<font",
"%s should not use deprecated <font> tag", tc.name)
assert.NotContains(t, tc.html, "<center",
"%s should not use deprecated <center> tag", tc.name)
// Check modern structure
assert.Contains(t, tc.html, "<head>",
"%s should have <head> section", tc.name)
assert.Contains(t, tc.html, "<body",
"%s should have <body> section", tc.name)
assert.Contains(t, tc.html, "<title>",
"%s should have <title> tag", tc.name)
})
}
}
func TestTemplateExternalLinkSecurity(t *testing.T) {
// Test that all external links (http/https) have proper security attributes
testCases := []struct {
name string
html string
externalURLs []string // URLs that should have security attributes
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
externalURLs: []string{
"https://github.com/juanfont/headscale/tree/main/docs",
"https://tailscale.com/kb/",
},
},
{
name: "Register Web",
html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
externalURLs: []string{}, // No external links
},
{
name: "Windows Config",
html: templates.Windows("https://example.com").Render(),
externalURLs: []string{
"https://tailscale.com/download/windows",
},
},
{
name: "Apple Config",
html: templates.Apple("https://example.com").Render(),
externalURLs: []string{
"https://apps.apple.com/app/tailscale/id1470499037",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, url := range tc.externalURLs {
// Find the link tag containing this URL
if !strings.Contains(tc.html, url) {
t.Errorf("%s should contain external link %s", tc.name, url)
continue
}
// Check for rel="noreferrer noopener"
// We look for the pattern: href="URL"...rel="noreferrer noopener"
// The attributes might be in any order, so we check within a reasonable window
idx := strings.Index(tc.html, url)
if idx == -1 {
continue
}
// Look for the closing > of the <a> tag (within 200 chars should be safe)
endIdx := strings.Index(tc.html[idx:idx+200], ">")
if endIdx == -1 {
endIdx = 200
}
linkTag := tc.html[idx : idx+endIdx]
assert.Contains(t, linkTag, `rel="noreferrer noopener"`,
"%s external link %s should have rel=\"noreferrer noopener\"", tc.name, url)
assert.Contains(t, linkTag, `target="_blank"`,
"%s external link %s should have target=\"_blank\"", tc.name, url)
}
})
}
}
func TestTemplateAccessibilityAttributes(t *testing.T) {
// Test that all templates have proper accessibility attributes
testCases := []struct {
name string
html string
}{
{
name: "OIDC Callback",
html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
},
{
name: "Register Web",
html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
},
{
name: "Windows Config",
html: templates.Windows("https://example.com").Render(),
},
{
name: "Apple Config",
html: templates.Apple("https://example.com").Render(),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Check for translate="no" on body tag to prevent browser translation
// This is important for technical documentation with commands
assert.Contains(t, tc.html, `translate="no"`,
"%s should have translate=\"no\" attribute on body tag", tc.name)
})
}
}