Skip to content

Commit 663dc72

Browse files
authored
feat(ui): add new rudimentory UI & dockerfile (#23)
* feat(ui): add new rudimentory UI & dockerfile * fix(ci): build templ * feat(ci): try out docker build * push
1 parent 25cf324 commit 663dc72

File tree

17 files changed

+295
-7
lines changed

17 files changed

+295
-7
lines changed

.air.toml

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ tmp_dir = "tmp"
44

55
[build]
66
bin = "./tmp/tailout"
7-
cmd = "go mod tidy && go build -o ./tmp/tailout ."
7+
cmd = "go mod tidy && templ generate && go build -o ./tmp/tailout ."
88
delay = 1000
99
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
1010
exclude_file = []
11-
exclude_regex = ["_test.go"]
11+
exclude_regex = ["_test.go", ".*_templ\\.go$"]
1212
exclude_unchanged = false
1313
follow_symlink = false
1414
full_bin = ""
1515
include_dir = []
16-
include_ext = ["go", "tpl", "tmpl", "html"]
16+
include_ext = ["go", "tpl", "tmpl", "html", "templ"]
1717
kill_delay = "0s"
1818
log = "build-errors.log"
1919
send_interrupt = false

.github/actions/build/action.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ inputs:
1010
runs:
1111
using: "composite"
1212
steps:
13+
- uses: actions/setup-go@v5
14+
with:
15+
go-version: stable
16+
- name: Generate templ code
17+
uses: capthiron/templ-generator-action@v1
18+
with:
19+
commit: "false"
20+
setup-go: "false"
21+
directory: "internal/views"
1322
- name: Build ${{ inputs.binary_name }}
1423
run: go build -o bin/${{ inputs.binary_name }}
1524
shell: bash

.github/workflows/build-and-push.yaml

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Create and publish Tailout Docker image
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
types: [opened, synchronize, reopened]
9+
10+
env:
11+
REGISTRY: ghcr.io
12+
IMAGE_NAME: ${{ github.repository }}
13+
14+
jobs:
15+
build-and-push-image:
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: read
19+
packages: write
20+
attestations: write
21+
id-token: write
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
- name: Log in to the Container registry
26+
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
27+
with:
28+
registry: ${{ env.REGISTRY }}
29+
username: ${{ github.actor }}
30+
password: ${{ secrets.GITHUB_TOKEN }}
31+
- name: Extract metadata (tags, labels) for Docker
32+
id: meta
33+
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
34+
with:
35+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
36+
- name: Build and push Docker image
37+
id: push
38+
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
39+
with:
40+
context: .
41+
push: true
42+
tags: ${{ steps.meta.outputs.tags }}
43+
labels: ${{ steps.meta.outputs.labels }}
44+
- name: Generate artifact attestation
45+
uses: actions/attest-build-provenance@v1
46+
with:
47+
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
48+
subject-digest: ${{ steps.push.outputs.digest }}
49+
push-to-registry: true

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ config.yaml
2525

2626
tmp/
2727
bin/
28+
29+
*_templ.go

Dockerfile

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
FROM golang:1.22.5 as fetch-stage
2+
3+
COPY go.mod go.sum /app/
4+
WORKDIR /app
5+
RUN go mod download
6+
7+
FROM ghcr.io/a-h/templ:latest AS generate-stage
8+
COPY --chown=65532:65532 . /app
9+
WORKDIR /app
10+
RUN ["templ", "generate"]
11+
12+
FROM cosmtrek/air as development
13+
COPY --from=generate-stage /ko-app/templ /bin/templ
14+
COPY --chown=65532:65532 . /app
15+
WORKDIR /app
16+
EXPOSE 3000
17+
ENTRYPOINT ["air"]
18+
19+
FROM golang:1.22.5 AS build-stage
20+
COPY --from=generate-stage /app /app
21+
WORKDIR /app
22+
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o /app/app
23+
24+
FROM gcr.io/distroless/base-debian12 AS deploy-stage
25+
WORKDIR /
26+
COPY --from=build-stage /app/app /app
27+
EXPOSE 3000
28+
USER nonroot:nonroot
29+
ENTRYPOINT ["/app"]

cmd/cmd.go

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func buildTailoutCommand(app *tailout.App) *cobra.Command {
2525
cmd.AddCommand(buildInitCommand(app))
2626
cmd.AddCommand(buildStatusCommand(app))
2727
cmd.AddCommand(buildStopCommand(app))
28+
cmd.AddCommand(buildUiCommand(app))
2829

2930
return cmd
3031
}

cmd/ui.go

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cmd
2+
3+
import (
4+
"github.com/cterence/tailout/tailout"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
// uiCmd represents the UI command
9+
func buildUiCommand(app *tailout.App) *cobra.Command {
10+
cmd := &cobra.Command{
11+
Args: cobra.ArbitraryArgs,
12+
Use: "ui",
13+
Short: "Start the Tailout UI",
14+
RunE: func(cmd *cobra.Command, args []string) error {
15+
err := app.Ui(args)
16+
if err != nil {
17+
return err
18+
}
19+
return nil
20+
},
21+
}
22+
23+
cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts")
24+
cmd.PersistentFlags().StringVarP(&app.Config.Ui.Address, "address", "a", "127.0.0.1", "Address to bind the UI to")
25+
cmd.PersistentFlags().StringVarP(&app.Config.Ui.Port, "port", "p", "3000", "Port to bind the UI to")
26+
27+
return cmd
28+
}

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.22.0
55
toolchain go1.22.5
66

77
require (
8+
github.com/a-h/templ v0.2.747
89
github.com/adhocore/chin v1.1.0
910
github.com/aws/aws-sdk-go-v2 v1.30.3
1011
github.com/aws/aws-sdk-go-v2/config v1.27.27

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
22
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3+
github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg=
4+
github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4=
35
github.com/adhocore/chin v1.1.0 h1:RuBkSBhtGpW2l6y9d/YSj7ZJSQINJgjodoD0OB1coAo=
46
github.com/adhocore/chin v1.1.0/go.mod h1:X6ey2uVyRozOnqd/sFb/k0pxY1tnm+Msigh1m2jlfbg=
57
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=

internal/assets/spinner.svg

+4
Loading
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package components
2+
3+
templ Footer() {
4+
<footer class="fixed p-1 bottom-0 bg-gray-100 w-full border-t">
5+
<div class="md:container md:mx-auto">
6+
<a class="rounded-lg p-4 text-s text-blue-600 visited:text-purple-600 text-center" href="https://github.com/cterence/dead-drop" target="_blank">
7+
github.com/cterence/tailout
8+
</a>
9+
</div>
10+
</footer>
11+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package components
2+
3+
templ Header() {
4+
<head>
5+
<script src="https://unpkg.com/htmx.org@2.0.0" integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw" crossorigin="anonymous"></script>
6+
<script src="https://cdn.tailwindcss.com"></script>
7+
<meta charset="UTF-8"/>
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
9+
<title>tailout.</title>
10+
</head>
11+
}

internal/views/components/title.templ

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package components
2+
3+
templ Title() {
4+
<a href="/" class="text-4xl">tailout.</a>
5+
}

internal/views/index.templ

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package views
2+
3+
import "github.com/cterence/tailout/internal/views/components"
4+
5+
templ Index() {
6+
<!DOCTYPE html>
7+
<html lang="en" class="text-gray-900 antialiased leading-tight">
8+
@components.Header()
9+
<body class="min-h-screen bg-gray-100 p-4">
10+
<div class="md:container md:mx-auto">
11+
@components.Title()
12+
<h2 class="text-xl my-4 text-gray-600">create an exit node in your tailnet in seconds.</h2>
13+
<div>
14+
<button id="create-btn" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 mr-2 rounded" hx-post="/create" hx-indicator="#spinner" hx-target="#create-btn" hx-on::before-request="disableButton(event)" hx-on::after-request="enableButton(event)" hx-swap="none">
15+
Create exit node
16+
// <img id="spinner" class="htmx-indicator" src="/assets/spinner.svg" alt="spinner"/>
17+
</button>
18+
<button id="stop-btn" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" hx-post="/stop" hx-swap="none" hx-indicator="#spinner" hx-on::before-request="disableButton(event)" hx-on::after-request="enableButton(event)">
19+
Stop all exit nodes
20+
</button>
21+
</div>
22+
// Table of exit nodes
23+
<div class="overflow-x-auto my-4">
24+
<table class="table-auto w-full text-sm text-left text-gray-500">
25+
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
26+
<tr>
27+
<th class="px-4 py-2">Hostname</th>
28+
<th class="px-4 py-2">Address</th>
29+
<th class="px-4 py-2">Last seen</th>
30+
</tr>
31+
</thead>
32+
<tbody hx-get="/status" hx-trigger="load,every 5s"></tbody>
33+
</table>
34+
</div>
35+
</div>
36+
@components.Footer()
37+
</body>
38+
<script>
39+
function disableButton(e) {
40+
htmx.removeClass(e.detail.elt, 'hover:bg-blue-700');
41+
htmx.removeClass(e.detail.elt, 'bg-blue-500');
42+
htmx.addClass(e.detail.elt, 'cursor-not-allowed');
43+
htmx.addClass(e.detail.elt, 'bg-gray-300');
44+
e.disabled = true;
45+
}
46+
47+
function enableButton(e) {
48+
htmx.removeClass(e.detail.elt, 'cursor-not-allowed');
49+
htmx.removeClass(e.detail.elt, 'bg-gray-300');
50+
htmx.addClass(e.detail.elt, 'hover:bg-blue-700');
51+
htmx.addClass(e.detail.elt, 'bg-blue-500');
52+
e.disabled = false;
53+
}
54+
</script>
55+
</html>
56+
}

tailout/config/config.go

+6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Config struct {
1616
Tailscale TailscaleConfig `mapstructure:"tailscale"`
1717
Create CreateConfig `mapstructure:"create"`
1818
Stop StopConfig `mapstructure:"stop"`
19+
Ui UiConfig `mapstructure:"ui"`
1920
}
2021

2122
type CreateConfig struct {
@@ -33,6 +34,11 @@ type StopConfig struct {
3334
All bool `mapstructure:"all"`
3435
}
3536

37+
type UiConfig struct {
38+
Port string `mapstructure:"port"`
39+
Address string `mapstructure:"address"`
40+
}
41+
3642
type Policy struct {
3743
ACLs []ACL `json:"acls,omitempty"`
3844
Hosts map[string]string `json:"hosts,omitempty"`

tailout/create.go

+1-4
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ sudo echo "sudo shutdown" | at now + ` + fmt.Sprint(durationMinutes) + ` minutes
204204
// Call internal.GetNodes periodically and search for the instance
205205
// If the instance is found, print the command to use it as an exit node
206206

207-
timeout := time.Now().Add(2 * time.Minute)
207+
timeout := time.Now().Add(3 * time.Minute)
208208

209209
client, err := tailscale.NewClient(app.Config.Tailscale.APIKey, app.Config.Tailscale.Tailnet)
210210
if err != nil {
@@ -268,9 +268,6 @@ found:
268268
if connect {
269269
fmt.Println()
270270
args := []string{nodeName}
271-
if nonInteractive {
272-
args = append(args, "--non-interactive")
273-
}
274271
err = app.Connect(args)
275272
if err != nil {
276273
return fmt.Errorf("failed to connect to node: %w", err)

tailout/ui.go

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package tailout
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net/http"
7+
8+
"github.com/cterence/tailout/internal"
9+
"github.com/cterence/tailout/internal/views"
10+
"github.com/tailscale/tailscale-client-go/tailscale"
11+
12+
"github.com/a-h/templ"
13+
)
14+
15+
func (app *App) Ui(args []string) error {
16+
indexComponent := views.Index()
17+
app.Config.NonInteractive = true
18+
19+
client, err := tailscale.NewClient(app.Config.Tailscale.APIKey, app.Config.Tailscale.Tailnet)
20+
if err != nil {
21+
return fmt.Errorf("failed to create tailscale client: %w", err)
22+
}
23+
24+
http.Handle("/", templ.Handler(indexComponent))
25+
26+
http.HandleFunc("/create", func(w http.ResponseWriter, r *http.Request) {
27+
slog.Info("Creating tailout node")
28+
go func() {
29+
err := app.Create()
30+
if err != nil {
31+
slog.Error("failed to create node: " + err.Error())
32+
}
33+
}()
34+
w.WriteHeader(http.StatusCreated)
35+
})
36+
37+
http.HandleFunc("/stop", func(w http.ResponseWriter, r *http.Request) {
38+
slog.Info("Stopping tailout nodes")
39+
app.Config.Stop.All = true
40+
go func() {
41+
err := app.Stop(nil)
42+
if err != nil {
43+
slog.Error("failed to create node: " + err.Error())
44+
}
45+
}()
46+
w.WriteHeader(http.StatusNoContent)
47+
})
48+
49+
http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
50+
nodes, err := internal.GetActiveNodes(client)
51+
if err != nil {
52+
slog.Error("failed to get active nodes: " + err.Error())
53+
w.WriteHeader(http.StatusInternalServerError)
54+
return
55+
}
56+
table := ""
57+
for _, node := range nodes {
58+
table += fmt.Sprintf("<tr class=\"bg-white border-b\"><td class=\"px-4 py-2\">%s</td><td class=\"px-4 py-2\">%s</td><td class=\"px-4 py-2\">%s</td></tr>", node.Hostname, node.Addresses[0], node.LastSeen)
59+
}
60+
w.Write([]byte(table))
61+
})
62+
63+
http.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+
w.Write([]byte(`{"status": {"server": "OK"}}`))
65+
}))
66+
67+
// Serve assets files
68+
http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("internal/assets"))))
69+
70+
slog.Info("Listening on " + app.Config.Ui.Address + ":" + app.Config.Ui.Port)
71+
err = http.ListenAndServe(app.Config.Ui.Address+":"+app.Config.Ui.Port, nil)
72+
if err != nil {
73+
slog.Error("Failed to start server")
74+
panic(err)
75+
}
76+
return nil
77+
}

0 commit comments

Comments
 (0)