mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 13:35:12 +09:00
Users had to SSH in to run `cloudflared tunnel login` before any of
the Tunnels tab worked. Wire up the OAuth flow so the panel can
trigger it and surface the auth URL for the user to open from their
own browser.
Agent:
- New cloudflared.LoginEvent + Manager.Login() that spawns the
process, regex-matches the dash.cloudflare.com/argotunnel URL on
stdout/stderr, and watches cert.pem in parallel as a reliable
completion signal. 15-minute hard ceiling.
- New cloudflared:login action returns {job_id, channel} immediately;
the goroutine emits start → status (with auth_url) → done events
on the job channel.
- Non-linux stub returns "unsupported".
- jobs.HasTerminated() helper so handler shutdown paths can avoid
double-emitting the terminator.
Panel:
- POST /servers/<id>/cloudflared/login + RemoteCloudflaredService.login
- ApiService.startRemoteCloudflaredLogin
Frontend:
- CloudflaredTab gains a "Login to Cloudflare" CTA when not authed.
Click → starts the agent flow, joins the job's Socket.IO room,
listens for the auth_url event, and renders an Open-in-Browser
button. Once the agent emits done, refreshes capabilities + status
+ tunnel list automatically — the rest of the tab unlocks itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
9.4 KiB
Go
303 lines
9.4 KiB
Go
//go:build linux
|
|
|
|
package cloudflared
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// New returns a Linux Manager that drives `cloudflared`.
|
|
func New() Manager { return &linuxManager{} }
|
|
|
|
type linuxManager struct{}
|
|
|
|
// validNameRegex constrains tunnel names to characters that won't
|
|
// trip cloudflared or shell escaping. Matches the cloudflared docs:
|
|
// up to 32 chars, alphanumeric + dash + underscore.
|
|
var validNameRegex = regexp.MustCompile(`^[A-Za-z0-9_\-]{1,32}$`)
|
|
|
|
// validHostnameRegex is a permissive FQDN check — no protocol, no
|
|
// path, no spaces. Cloudflare itself does the authoritative check.
|
|
var validHostnameRegex = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+$`)
|
|
|
|
// certSearchPaths covers the two install patterns: the user ran
|
|
// `cloudflared tunnel login` as a regular user (~/.cloudflared) or as
|
|
// root / via the official install (/etc/cloudflared).
|
|
func certSearchPaths() []string {
|
|
paths := []string{"/etc/cloudflared/cert.pem"}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
paths = append(paths, filepath.Join(home, ".cloudflared", "cert.pem"))
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func findCert() (string, bool) {
|
|
for _, p := range certSearchPaths() {
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (l *linuxManager) Status(ctx context.Context) (*Status, error) {
|
|
binPath, ok := hasCloudflared()
|
|
if !ok {
|
|
return &Status{
|
|
Available: false,
|
|
Reason: "cloudflared not installed on host",
|
|
LoginHint: "Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/",
|
|
}, nil
|
|
}
|
|
|
|
cert, authed := findCert()
|
|
|
|
// Best-effort version probe — failure is non-fatal, we still
|
|
// report Available=true based on PATH.
|
|
verCmd := exec.CommandContext(ctx, binPath, "--version")
|
|
verOut, _ := verCmd.Output()
|
|
version := strings.TrimSpace(strings.SplitN(string(verOut), "\n", 2)[0])
|
|
|
|
st := &Status{
|
|
Available: true,
|
|
Authenticated: authed,
|
|
CertPath: cert,
|
|
Version: version,
|
|
}
|
|
if !authed {
|
|
st.LoginHint = "Run on the server: sudo cloudflared tunnel login"
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// runTunnel runs `cloudflared tunnel <args>` and returns
|
|
// (stdout, error). Errors include the full stderr because the user
|
|
// will likely see them in the panel UI when something goes wrong.
|
|
func (l *linuxManager) runTunnel(ctx context.Context, args ...string) ([]byte, error) {
|
|
args = append([]string{"tunnel"}, args...)
|
|
cmd := exec.CommandContext(ctx, "cloudflared", args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("cloudflared %s: %w (%s)",
|
|
strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func (l *linuxManager) List(ctx context.Context) ([]Tunnel, error) {
|
|
out, err := l.runTunnel(ctx, "list", "--output", "json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// cloudflared prints `[]` (sometimes with trailing whitespace) when
|
|
// no tunnels exist; json.Unmarshal handles both. Tunnel rows have
|
|
// more fields than we surface — we cherry-pick id, name,
|
|
// created_at, connections.
|
|
var raw []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt string `json:"created_at"`
|
|
Connections []map[string]interface{} `json:"connections"`
|
|
}
|
|
if err := json.Unmarshal(bytes.TrimSpace(out), &raw); err != nil {
|
|
return nil, fmt.Errorf("parse tunnel list: %w", err)
|
|
}
|
|
|
|
out2 := make([]Tunnel, 0, len(raw))
|
|
for _, r := range raw {
|
|
conns := make([]string, 0, len(r.Connections))
|
|
for _, c := range r.Connections {
|
|
if origin, ok := c["origin_ip"].(string); ok && origin != "" {
|
|
conns = append(conns, origin)
|
|
}
|
|
}
|
|
out2 = append(out2, Tunnel{
|
|
ID: r.ID, Name: r.Name, CreatedAt: r.CreatedAt, Connections: conns,
|
|
})
|
|
}
|
|
return out2, nil
|
|
}
|
|
|
|
func (l *linuxManager) Create(ctx context.Context, req CreateRequest) (*Tunnel, error) {
|
|
if !validNameRegex.MatchString(req.Name) {
|
|
return nil, fmt.Errorf("invalid tunnel name (alphanumeric, -, _, max 32 chars)")
|
|
}
|
|
// `cloudflared tunnel create <name>` prints something like:
|
|
// Created tunnel <name> with id <UUID>
|
|
// Newer versions also have --output json on create; we parse the
|
|
// stdout text to be portable across versions.
|
|
out, err := l.runTunnel(ctx, "create", req.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Look up by name afterwards so we have the full Tunnel record
|
|
// (the create stdout is informational, not structured JSON across
|
|
// all cloudflared versions).
|
|
_ = out
|
|
tunnels, err := l.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, t := range tunnels {
|
|
if t.Name == req.Name {
|
|
return &t, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("tunnel created but not found in list")
|
|
}
|
|
|
|
func (l *linuxManager) Route(ctx context.Context, req RouteRequest) error {
|
|
if req.TunnelRef == "" {
|
|
return fmt.Errorf("tunnel_ref is required")
|
|
}
|
|
if !validHostnameRegex.MatchString(req.Hostname) {
|
|
return fmt.Errorf("invalid hostname")
|
|
}
|
|
// `cloudflared tunnel route dns <tunnel> <hostname>` creates the
|
|
// CNAME in Cloudflare DNS.
|
|
_, err := l.runTunnel(ctx, "route", "dns", req.TunnelRef, req.Hostname)
|
|
return err
|
|
}
|
|
|
|
// loginURLRegex matches the `https://dash.cloudflare.com/argotunnel?...`
|
|
// URL cloudflared prints to stdout during `tunnel login`. The exact
|
|
// text varies by version ("Please open the following URL", "Please
|
|
// visit", etc.), so we match the URL itself rather than the prompt.
|
|
var loginURLRegex = regexp.MustCompile(`https://dash\.cloudflare\.com/argotunnel\?\S+`)
|
|
|
|
// Login spawns `cloudflared tunnel login` and streams a typed event
|
|
// channel. The first event after the URL is found carries AuthURL;
|
|
// the channel closes (with a final Done event) when cert.pem appears
|
|
// or the process exits / 15-minute hard ceiling fires.
|
|
//
|
|
// We can't just look at process exit because cloudflared sometimes
|
|
// prints the URL and exits cleanly while waiting on the OAuth callback
|
|
// in a separate flow. The cert.pem watcher is the source of truth.
|
|
func (l *linuxManager) Login(ctx context.Context) (<-chan LoginEvent, error) {
|
|
if _, ok := hasCloudflared(); !ok {
|
|
return nil, fmt.Errorf("cloudflared not installed")
|
|
}
|
|
// If cert.pem already exists, short-circuit — login would
|
|
// overwrite, which surprises the user.
|
|
if cert, ok := findCert(); ok {
|
|
out := make(chan LoginEvent, 1)
|
|
out <- LoginEvent{Done: true, CertPath: cert, Line: "already authenticated"}
|
|
close(out)
|
|
return out, nil
|
|
}
|
|
|
|
out := make(chan LoginEvent, 8)
|
|
|
|
// 15-minute ceiling on the login flow — long enough for a coffee
|
|
// break, short enough to garbage-collect a forgotten browser tab.
|
|
cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
|
|
|
cmd := exec.CommandContext(cctx, "cloudflared", "tunnel", "login")
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("start cloudflared: %w", err)
|
|
}
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(out)
|
|
|
|
// Tee stdout + stderr into a single line stream. cloudflared
|
|
// has historically printed the auth URL on stderr (older
|
|
// versions) and stdout (newer), so listen to both.
|
|
lines := make(chan string, 32)
|
|
go scanLines(stdout, lines)
|
|
go scanLines(stderr, lines)
|
|
|
|
urlSent := false
|
|
// Best-effort cert.pem watcher running in parallel. cloudflared
|
|
// itself exits as soon as the cert is fetched on success, but
|
|
// the watcher is the more reliable signal in case of weird
|
|
// stdout buffering.
|
|
certCheck := time.NewTicker(2 * time.Second)
|
|
defer certCheck.Stop()
|
|
|
|
// Drain pipes + watch cert in the same select loop.
|
|
for {
|
|
select {
|
|
case <-cctx.Done():
|
|
out <- LoginEvent{Done: true, Error: "login timed out"}
|
|
_ = cmd.Process.Kill()
|
|
return
|
|
case line, ok := <-lines:
|
|
if !ok {
|
|
// pipes closed — wait for cmd to reap and emit final.
|
|
_ = cmd.Wait()
|
|
if cert, ok := findCert(); ok {
|
|
out <- LoginEvent{Done: true, CertPath: cert}
|
|
} else {
|
|
out <- LoginEvent{Done: true, Error: "cloudflared exited without writing cert.pem"}
|
|
}
|
|
return
|
|
}
|
|
if !urlSent {
|
|
if u := loginURLRegex.FindString(line); u != "" {
|
|
urlSent = true
|
|
out <- LoginEvent{AuthURL: u, Line: line}
|
|
continue
|
|
}
|
|
}
|
|
out <- LoginEvent{Line: line}
|
|
case <-certCheck.C:
|
|
if cert, ok := findCert(); ok {
|
|
out <- LoginEvent{Done: true, CertPath: cert}
|
|
_ = cmd.Process.Kill()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return out, nil
|
|
}
|
|
|
|
// scanLines pumps r line-by-line into out. Closes out when r is
|
|
// exhausted; multiple producers are fine because the consumer treats
|
|
// a single closed channel as "all done."
|
|
func scanLines(r io.ReadCloser, out chan<- string) {
|
|
defer r.Close()
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
out <- sc.Text()
|
|
}
|
|
}
|
|
|
|
func (l *linuxManager) Delete(ctx context.Context, ref string) error {
|
|
if ref == "" {
|
|
return fmt.Errorf("ref is required")
|
|
}
|
|
// -f forces deletion when the tunnel still has active connections;
|
|
// safer default for a panel-driven flow.
|
|
_, err := l.runTunnel(ctx, "delete", "-f", ref)
|
|
return err
|
|
}
|