Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/k3s-ca.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ jobs:
run: |
shellcheck -s sh -S warning pkg/templates/scripts/tn-k3s-ca.sh
- name: go test
run: go test ./pkg/templates/... ./test/k3s-ca/...
run: |
go test ./test/k3s-ca/... ./cmd/...
(cd pkg/templates && go test ./...)

docker-matrix:
if: github.event_name == 'workflow_dispatch'
Expand Down
2 changes: 1 addition & 1 deletion .junie/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Identity
- **Binary Name**: `tnctl`
- **Module Name**: `teknoir/cli`
- **Module Name**: `github.com/teknoir/cli`
- **Purpose**: A command-line interface for managing Teknoir cloud resources and edge devices.

## Development Rules
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ export TNCTL_DOMAIN=teknoir.cloud
export TNCTL_NAMESPACE=my-namespace
```

## Using the templates from Go

The device bootstrap script templates are published as a separate, dependency-free Go module, so other
programs can render the same scripts `tnctl bootstrap` produces without pulling in the CLI's dependencies:

```sh
go get github.com/teknoir/cli/pkg/templates@<commit>
```

```go
import "github.com/teknoir/cli/pkg/templates"

script, err := templates.RenderServer(templates.TemplateData{Domain: "example.com", Namespace: "my-teamspace", DeviceID: "my-device" /* ... */})
```

`RenderServer` validates the input first (`ValidateTemplateData` rejects `"`, `` ` ``, `$` and `\` in every field,
and newlines in every field except `RSAPrivate` and `RSAPublic`). Errors name the field, never the value.
`TemplatesSHA256()` returns a fingerprint of all embedded templates, to detect a stale rendered script.
No tags are published yet, so pin a commit (`go get` resolves it to a pseudo-version).

## License

Apache License 2.0
Expand Down
2 changes: 1 addition & 1 deletion cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cmd
import (
"fmt"
"github.com/spf13/cobra"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

var addCmd = &cobra.Command{
Expand Down
6 changes: 3 additions & 3 deletions cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (
"os"
"text/template"

"teknoir/cli/pkg/api"
"teknoir/cli/pkg/config"
"teknoir/cli/pkg/templates"
"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/templates"

"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
Expand Down
2 changes: 1 addition & 1 deletion cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cmd
import (
"testing"

"teknoir/cli/pkg/templates"
"github.com/teknoir/cli/pkg/templates"
)

func TestBootstrapK3SVersionFlag(t *testing.T) {
Expand Down
6 changes: 3 additions & 3 deletions cmd/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"fmt"
"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
"teknoir/cli/pkg/api"
"teknoir/cli/pkg/config"
"teknoir/cli/pkg/ui"
"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/ui"
)

var deviceCmd = &cobra.Command{
Expand Down
6 changes: 3 additions & 3 deletions cmd/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import (
"strings"

"github.com/spf13/cobra"
"teknoir/cli/pkg/api"
"teknoir/cli/pkg/config"
"teknoir/cli/pkg/ui"
"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/ui"
)

var devicesCmd = &cobra.Command{
Expand Down
2 changes: 1 addition & 1 deletion cmd/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
"slices"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

var domainCmd = &cobra.Command{
Expand Down
2 changes: 1 addition & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

"github.com/spf13/cobra"
"golang.org/x/oauth2"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

var (
Expand Down
6 changes: 3 additions & 3 deletions cmd/ns.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import (
"fmt"
"time"

"teknoir/cli/pkg/api"
authPkg "teknoir/cli/pkg/auth"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/api"
authPkg "github.com/teknoir/cli/pkg/auth"
"github.com/teknoir/cli/pkg/config"

"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
Expand Down
4 changes: 2 additions & 2 deletions cmd/port_forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (

"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
"teknoir/cli/pkg/api"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"
)

var (
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (

"github.com/spf13/cobra"
"github.com/spf13/viper"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

var (
Expand Down
4 changes: 2 additions & 2 deletions cmd/socks_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (

"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
"teknoir/cli/pkg/api"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"
)

var (
Expand Down
4 changes: 2 additions & 2 deletions cmd/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import (
"os"
"os/exec"

"teknoir/cli/pkg/api"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"

"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ flowchart LR

!!! info "Project facts"
- **Binary name:** `tnctl`
- **Go module:** `teknoir/cli`
- **Go module:** `github.com/teknoir/cli`
- **Default domain:** `teknoir.cloud`
- **Config file:** `~/.tnctl.yaml`
- **License:** Apache 2.0
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module teknoir/cli
module github.com/teknoir/cli

go 1.25.0

Expand Down Expand Up @@ -41,3 +41,7 @@ require (
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

require github.com/teknoir/cli/pkg/templates v0.0.0

replace github.com/teknoir/cli/pkg/templates => ./pkg/templates
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package main

import "teknoir/cli/cmd"
import "github.com/teknoir/cli/cmd"

func main() {
cmd.Execute()
Expand Down
2 changes: 1 addition & 1 deletion pkg/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"net/http"
"strings"

"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

// Request performs an authenticated API request and handles non-JSON responses gracefully.
Expand Down
2 changes: 1 addition & 1 deletion pkg/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"strings"
"testing"

"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

func TestRequest_HTMLDetection(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions pkg/api/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import (
"net/url"
"time"

"teknoir/cli/pkg/auth"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/auth"
"github.com/teknoir/cli/pkg/config"
)

type DeviceAlert struct {
Expand Down
2 changes: 1 addition & 1 deletion pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"time"

"golang.org/x/oauth2"
"teknoir/cli/pkg/config"
"github.com/teknoir/cli/pkg/config"
)

// GetValidToken returns a valid access token, refreshing it if necessary.
Expand Down
3 changes: 3 additions & 0 deletions pkg/templates/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/teknoir/cli/pkg/templates

go 1.22
18 changes: 18 additions & 0 deletions pkg/templates/golden_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package templates

// goldenData is the fixed placeholder input for the golden files. The values
// are non-PEM, non-secret placeholders.
func goldenData() TemplateData {
return TemplateData{
Domain: "example.test",
Namespace: "golden-ns",
DeviceID: "golden-device",
RSAPrivate: "TEST-ONLY-rsa-private\nTEST-ONLY-rsa-private-line2",
RSAPublic: "TEST-ONLY-rsa-public\nTEST-ONLY-rsa-public-line2",
Username: "golden-user",
Password: "TEST-ONLY-password",
PublicSSHKey: "ssh-ed25519 TEST-ONLY-ssh-public golden@example.test",
K3SToken: "TEST-ONLY-k3s-token",
DockerSecret: "TEST-ONLY-docker-secret",
}
}
106 changes: 106 additions & 0 deletions pkg/templates/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package templates

import (
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"strings"
)

// allTemplatesFS holds every embedded template and partial. It is used only
// to fingerprint the templates (see TemplatesSHA256).
//
//go:embed *.sh.tmpl partials/*.tmpl
var allTemplatesFS embed.FS

// unsafeChars are rejected in every TemplateData field. The templates place
// values inside double-quoted and heredoc shell contexts, where these
// characters would allow quoting breakouts or expansion.
const unsafeChars = "\"`$\\"

type field struct {
name string
value string
multiline bool
}

// ValidateTemplateData rejects TemplateData that is unsafe to render into a
// shell script. The characters " ` $ and \ are rejected in every field. A
// newline (or carriage return) is rejected in every field except RSAPrivate
// and RSAPublic, which are multi-line PEM material.
//
// Errors name the field and the offending character class, never the value.
func ValidateTemplateData(d TemplateData) error {
fields := []field{
{"Domain", d.Domain, false},
{"Namespace", d.Namespace, false},
{"DeviceID", d.DeviceID, false},
{"RSAPrivate", d.RSAPrivate, true},
{"RSAPublic", d.RSAPublic, true},
{"Username", d.Username, false},
{"Password", d.Password, false},
{"PublicSSHKey", d.PublicSSHKey, false},
{"K3SToken", d.K3SToken, false},
{"DockerSecret", d.DockerSecret, false},
}
for _, f := range fields {
if strings.ContainsAny(f.value, unsafeChars) {
return fmt.Errorf("template data field %s contains a character that is unsafe in a shell script (double quote, backtick, dollar or backslash)", f.name)
}
if !f.multiline && strings.ContainsAny(f.value, "\n\r") {
return fmt.Errorf("template data field %s must not contain a newline", f.name)
}
}
return nil
}

// RenderServer validates d and renders the server (k3s server node) bootstrap
// script. It produces the same bytes as ServerTemplate.Execute for valid input.
func RenderServer(d TemplateData) ([]byte, error) {
if err := ValidateTemplateData(d); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := ServerTemplate.Execute(&buf, d); err != nil {
return nil, fmt.Errorf("render server template: %w", err)
}
return buf.Bytes(), nil
}

// TemplatesSHA256 returns the hex sha256 fingerprint of the embedded agent,
// server and docker_server templates and all partials, taken in sorted path
// order. It changes whenever a template byte changes, so callers can detect
// that a rendered script is stale.
func TemplatesSHA256() string {
return hashTemplateFS(allTemplatesFS)
}

// hashTemplateFS hashes every file of fsys in lexical path order. Each file
// contributes its path, a NUL, its length and its content, so moving bytes
// between files changes the digest.
func hashTemplateFS(fsys fs.FS) string {
h := sha256.New()
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
b, err := fs.ReadFile(fsys, p)
if err != nil {
return err
}
fmt.Fprintf(h, "%s\x00%d\x00", p, len(b))
h.Write(b)
return nil
})
if err != nil {
// Embedded and in-memory file systems cannot fail here.
panic(fmt.Sprintf("templates: hash: %v", err))
}
return hex.EncodeToString(h.Sum(nil))
}
Loading
Loading