add tcp daemon listener with token auth
This commit is contained in:
@@ -17,6 +17,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
on demand and attaches a thin terminal client over the unix-socket
|
on demand and attaches a thin terminal client over the unix-socket
|
||||||
transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy
|
transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy
|
||||||
single-process path available as an escape hatch.
|
single-process path available as an escape hatch.
|
||||||
|
- `patterm daemon --listen HOST:PORT` can now opt into a TCP listener
|
||||||
|
for remote human clients, with the unix socket still enabled for
|
||||||
|
local clients.
|
||||||
|
- `patterm connect --host HOST:PORT [--token TOKEN]` attaches the thin
|
||||||
|
client to a remote daemon over the same transport protocol.
|
||||||
|
- TCP attaches now require a lightweight bearer token stored under
|
||||||
|
`$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches
|
||||||
|
remain exempt and rely on socket file permissions.
|
||||||
- patterm can now keep multiple local projects loaded in one loopback
|
- patterm can now keep multiple local projects loaded in one loopback
|
||||||
daemon core, with command-palette entries to switch the current
|
daemon core, with command-palette entries to switch the current
|
||||||
client view or open another project without tearing down processes
|
client view or open another project without tearing down processes
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ func main() {
|
|||||||
runDaemonCommand()
|
runDaemonCommand()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(os.Args) >= 2 && os.Args[1] == "connect" {
|
||||||
|
os.Args = append(os.Args[:1], os.Args[2:]...)
|
||||||
|
runConnectCommand()
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(os.Args) >= 2 && os.Args[1] == "ls" {
|
if len(os.Args) >= 2 && os.Args[1] == "ls" {
|
||||||
runDaemonList()
|
runDaemonList()
|
||||||
return
|
return
|
||||||
@@ -233,7 +238,10 @@ func runDaemonCommand() {
|
|||||||
runDaemonList()
|
runDaemonList()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var projectDir = flag.String("project", "", "initial project directory (default $PWD)")
|
var (
|
||||||
|
projectDir = flag.String("project", "", "initial project directory (default $PWD)")
|
||||||
|
listenAddr = flag.String("listen", "", "optional TCP listen address for remote human clients (for example 127.0.0.1:2488, 0.0.0.0:2488, or 2488)")
|
||||||
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -244,11 +252,69 @@ func runDaemonCommand() {
|
|||||||
} else if flag.NArg() > 0 {
|
} else if flag.NArg() > 0 {
|
||||||
cwd = flag.Arg(0)
|
cwd = flag.Arg(0)
|
||||||
}
|
}
|
||||||
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil {
|
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd, ListenAddr: *listenAddr}); err != nil {
|
||||||
die("daemon: %v", err)
|
die("daemon: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runConnectCommand() {
|
||||||
|
var (
|
||||||
|
host = flag.String("host", "", "remote daemon host:port")
|
||||||
|
token = flag.String("token", "", "remote daemon token (default PATTERM_TOKEN or stored token file)")
|
||||||
|
projectDir = flag.String("project", "", "project directory to request on the daemon")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
if *host == "" && flag.NArg() > 0 {
|
||||||
|
*host = flag.Arg(0)
|
||||||
|
}
|
||||||
|
if *host == "" {
|
||||||
|
die("connect: --host HOST:PORT is required")
|
||||||
|
}
|
||||||
|
tok := *token
|
||||||
|
if tok == "" {
|
||||||
|
tok = os.Getenv("PATTERM_TOKEN")
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
if stored, err := app.LoadClientToken(); err == nil {
|
||||||
|
tok = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
die("connect: token required via --token, PATTERM_TOKEN, or %s", mustTokenPath())
|
||||||
|
}
|
||||||
|
cwd := *projectDir
|
||||||
|
if cwd == "" {
|
||||||
|
var err error
|
||||||
|
cwd, err = os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
die("getwd: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr, err := app.DialTCPTransport(*host)
|
||||||
|
if err != nil {
|
||||||
|
die("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer tr.Close()
|
||||||
|
if err := app.RunAttachedClient(context.Background(), app.ClientOptions{
|
||||||
|
ProjectDir: cwd,
|
||||||
|
Transport: tr,
|
||||||
|
Stdin: os.Stdin,
|
||||||
|
Stdout: os.Stdout,
|
||||||
|
RawMode: true,
|
||||||
|
Token: tok,
|
||||||
|
}); err != nil {
|
||||||
|
die("connect: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustTokenPath() string {
|
||||||
|
path, err := app.ClientTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return "$XDG_DATA_HOME/patterm/clients/token"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func runDaemonList() {
|
func runDaemonList() {
|
||||||
projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList})
|
projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type ClientOptions struct {
|
|||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
RawMode bool
|
RawMode bool
|
||||||
AutoStart bool
|
AutoStart bool
|
||||||
|
Token string
|
||||||
Cols uint16
|
Cols uint16
|
||||||
Rows uint16
|
Rows uint16
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,14 @@ func RunAttachedClient(ctx context.Context, opts ClientOptions) error {
|
|||||||
return c.run(ctx)
|
return c.run(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DialTCPTransport(addr string) (protocol.Transport, error) {
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return protocol.NewConnTransport(conn), nil
|
||||||
|
}
|
||||||
|
|
||||||
func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) {
|
func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) {
|
||||||
socket, _, err := RuntimeDaemonPaths()
|
socket, _, err := RuntimeDaemonPaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,6 +129,7 @@ type netClient struct {
|
|||||||
out io.Writer
|
out io.Writer
|
||||||
raw bool
|
raw bool
|
||||||
projectDir string
|
projectDir string
|
||||||
|
token string
|
||||||
layout terminalLayout
|
layout terminalLayout
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -141,6 +151,7 @@ func newNetClient(opts ClientOptions) *netClient {
|
|||||||
out: opts.Stdout,
|
out: opts.Stdout,
|
||||||
raw: opts.RawMode,
|
raw: opts.RawMode,
|
||||||
projectDir: opts.ProjectDir,
|
projectDir: opts.ProjectDir,
|
||||||
|
token: opts.Token,
|
||||||
layout: layout,
|
layout: layout,
|
||||||
renderer: newViewportRenderer(layout),
|
renderer: newViewportRenderer(layout),
|
||||||
}
|
}
|
||||||
@@ -204,6 +215,7 @@ func (c *netClient) run(ctx context.Context) error {
|
|||||||
func (c *netClient) sendAttach() error {
|
func (c *netClient) sendAttach() error {
|
||||||
f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{
|
f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{
|
||||||
ProjectPath: c.projectPath(),
|
ProjectPath: c.projectPath(),
|
||||||
|
Token: c.token,
|
||||||
TermSize: protocol.Size{
|
TermSize: protocol.Size{
|
||||||
Cols: c.layout.childCols(),
|
Cols: c.layout.childCols(),
|
||||||
Rows: c.layout.childRows(),
|
Rows: c.layout.childRows(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,6 +24,10 @@ type DaemonOptions struct {
|
|||||||
ProjectDir string
|
ProjectDir string
|
||||||
SocketPath string
|
SocketPath string
|
||||||
PidPath string
|
PidPath string
|
||||||
|
ListenAddr string
|
||||||
|
Token string
|
||||||
|
TokenOut io.Writer
|
||||||
|
ListenReady chan string
|
||||||
Cols uint16
|
Cols uint16
|
||||||
Rows uint16
|
Rows uint16
|
||||||
}
|
}
|
||||||
@@ -113,28 +118,101 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tcpLn net.Listener
|
||||||
|
tcpToken := opts.Token
|
||||||
|
if opts.ListenAddr != "" {
|
||||||
|
addr := normalizeListenAddr(opts.ListenAddr)
|
||||||
|
tcpToken, err = ensureDaemonToken(tcpToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tcpLn, err = net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("daemon: listen tcp %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer tcpLn.Close()
|
||||||
|
if opts.ListenReady != nil {
|
||||||
|
select {
|
||||||
|
case opts.ListenReady <- tcpLn.Addr().String():
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := opts.TokenOut
|
||||||
|
if out == nil {
|
||||||
|
out = os.Stderr
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "patterm daemon listening on %s\npatterm token: %s\n", tcpLn.Addr().String(), tcpToken)
|
||||||
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
_ = ln.Close()
|
_ = ln.Close()
|
||||||
|
if tcpLn != nil {
|
||||||
|
_ = tcpLn.Close()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go acceptDaemonLoop(ctx, &wg, ln, "", cancel, registry, errCh)
|
||||||
|
if tcpLn != nil {
|
||||||
|
go acceptDaemonLoop(ctx, &wg, tcpLn, tcpToken, cancel, registry, errCh)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case err := <-errCh:
|
||||||
|
cancel()
|
||||||
|
wg.Wait()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptDaemonLoop(ctx context.Context, wg *sync.WaitGroup, ln net.Listener, authToken string, stop func(), registry *ProjectRegistry, errCh chan<- error) {
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
|
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
|
||||||
wg.Wait()
|
return
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
continue
|
select {
|
||||||
|
case errCh <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
handleDaemonConn(ctx, cancel, registry, protocol.NewConnTransport(conn))
|
handleDaemonConn(ctx, stop, registry, protocol.NewConnTransport(conn), authToken)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeListenAddr(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, _, err := net.SplitHostPort(addr); err == nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(addr, ":") {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(addr); err == nil {
|
||||||
|
return ":" + addr
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDaemonToken(token string) (string, error) {
|
||||||
|
if strings.TrimSpace(token) != "" {
|
||||||
|
return strings.TrimSpace(token), nil
|
||||||
|
}
|
||||||
|
return LoadOrCreateClientToken()
|
||||||
|
}
|
||||||
|
|
||||||
func prepareDaemonSocket(socketPath, pidPath string) (string, error) {
|
func prepareDaemonSocket(socketPath, pidPath string) (string, error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -163,7 +241,7 @@ func syscallSignal0(pid int) error {
|
|||||||
return syscall.Kill(pid, 0)
|
return syscall.Kill(pid, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport) {
|
func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport, authToken string) {
|
||||||
defer t.Close()
|
defer t.Close()
|
||||||
f, err := t.Recv()
|
f, err := t.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,6 +256,17 @@ func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistr
|
|||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
case protocol.FrameAttach:
|
case protocol.FrameAttach:
|
||||||
|
if authToken != "" {
|
||||||
|
attach, err := protocol.Decode[protocol.Attach](f)
|
||||||
|
if err != nil {
|
||||||
|
_ = sendProtocolError(t, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if attach.Token != authToken {
|
||||||
|
_ = sendProtocolError(t, "auth denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
handleDaemonAttach(ctx, registry, t, f)
|
handleDaemonAttach(ctx, registry, t, f)
|
||||||
default:
|
default:
|
||||||
_ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type))
|
_ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -85,6 +86,80 @@ func TestDaemonDetachReattachPreservesProcess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
|
||||||
|
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
|
||||||
|
projectDir := filepath.Join(root, "project")
|
||||||
|
if err := os.MkdirAll(projectDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir project: %v", err)
|
||||||
|
}
|
||||||
|
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
|
||||||
|
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
ready := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- RunDaemon(ctx, DaemonOptions{
|
||||||
|
ProjectDir: projectDir,
|
||||||
|
SocketPath: socket,
|
||||||
|
PidPath: pid,
|
||||||
|
ListenAddr: "127.0.0.1:0",
|
||||||
|
Token: "secret-token",
|
||||||
|
TokenOut: io.Discard,
|
||||||
|
ListenReady: ready,
|
||||||
|
Cols: 80,
|
||||||
|
Rows: 24,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
waitForSocket(t, socket, errCh)
|
||||||
|
tcpAddr := waitForTCPAddr(t, ready, errCh)
|
||||||
|
|
||||||
|
assertTCPAttachDenied(t, tcpAddr, "")
|
||||||
|
assertTCPAttachDenied(t, tcpAddr, "wrong-token")
|
||||||
|
|
||||||
|
tcpClient := dialTCPDaemon(t, tcpAddr)
|
||||||
|
defer tcpClient.Close()
|
||||||
|
sendFrame(t, tcpClient, protocol.FrameAttach, protocol.Attach{
|
||||||
|
Token: "secret-token",
|
||||||
|
ProjectPath: projectDir,
|
||||||
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
||||||
|
})
|
||||||
|
expectFrame(t, tcpClient, protocol.FrameHello)
|
||||||
|
expectFrame(t, tcpClient, protocol.FrameProjectList)
|
||||||
|
expectFrame(t, tcpClient, protocol.FrameChrome)
|
||||||
|
data, _ := json.Marshal(map[string]any{
|
||||||
|
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; echo TCP-SNAPSHOT; sleep 30"},
|
||||||
|
"name": "tcp-survivor",
|
||||||
|
})
|
||||||
|
sendFrame(t, tcpClient, protocol.FramePaletteCommand, protocol.PaletteCommand{
|
||||||
|
Kind: "spawn_command",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
expectFrame(t, tcpClient, protocol.FramePaneSnapshot)
|
||||||
|
|
||||||
|
unixClient := dialDaemon(t, socket)
|
||||||
|
defer unixClient.Close()
|
||||||
|
sendFrame(t, unixClient, protocol.FrameAttach, protocol.Attach{
|
||||||
|
ProjectPath: projectDir,
|
||||||
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
||||||
|
})
|
||||||
|
expectFrame(t, unixClient, protocol.FrameHello)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("daemon returned error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("daemon did not stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
|
func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
@@ -114,6 +189,49 @@ func dialDaemon(t *testing.T, socket string) protocol.Transport {
|
|||||||
return protocol.NewConnTransport(conn)
|
return protocol.NewConnTransport(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dialTCPDaemon(t *testing.T, addr string) protocol.Transport {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial tcp daemon: %v", err)
|
||||||
|
}
|
||||||
|
return protocol.NewConnTransport(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForTCPAddr(t *testing.T, ready <-chan string, errCh <-chan error) string {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case addr := <-ready:
|
||||||
|
return addr
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
|
||||||
|
t.Skipf("tcp sockets unavailable in this sandbox: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("daemon exited before TCP listener was ready: %v", err)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("tcp listener was not ready")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTCPAttachDenied(t *testing.T, addr, token string) {
|
||||||
|
t.Helper()
|
||||||
|
client := dialTCPDaemon(t, addr)
|
||||||
|
defer client.Close()
|
||||||
|
sendFrame(t, client, protocol.FrameAttach, protocol.Attach{
|
||||||
|
Token: token,
|
||||||
|
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
||||||
|
})
|
||||||
|
f := expectFrame(t, client, protocol.FrameError)
|
||||||
|
msg, err := protocol.Decode[protocol.Error](f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode error frame: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Message, "auth denied") {
|
||||||
|
t.Fatalf("error message = %q, want auth denied", msg.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
|
func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f, err := protocol.NewFrame(typ, payload)
|
f, err := protocol.NewFrame(typ, payload)
|
||||||
|
|||||||
63
internal/app/token.go
Normal file
63
internal/app/token.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClientTokenPath() (string, error) {
|
||||||
|
base := os.Getenv("XDG_DATA_HOME")
|
||||||
|
if base == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
base = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "patterm", "clients", "token"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadClientToken() (string, error) {
|
||||||
|
path, err := ClientTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadOrCreateClientToken() (string, error) {
|
||||||
|
if token, err := LoadClientToken(); err == nil && token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
token, err := generateClientToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path, err := ClientTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateClientToken() (string, error) {
|
||||||
|
var b [32]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("token: random: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user