From 5458f0c66c2df066d29122af26327381f3c1923e Mon Sep 17 00:00:00 2001 From: Muthukumaran Navaneethakrishnan Date: Mon, 5 Oct 2026 20:15:54 +0530 Subject: [PATCH 1/2] v2: Go rewrite with durable routes, machine-wide list, stop/start/doctor/import Keeps every v1 command and flag working (cfex host:port, cfex host port, --https, --verify-cert, list, delete). Adds cfex add (one tunnel owned by cfex, additive ingress merge, launchd/systemd service), list of everything on the machine with live HTTP status, stop/start, status, doctor, import. Protected and client tunnels come from the user's config; the API token comes from the environment or an optional token_cmd. The bash script stays in bin/ until the Go binary ships. --- .github/workflows/ci.yml | 11 ++ .gitignore | 1 + Makefile | 9 ++ README.md | 33 ++++ cmd/cfex/add.go | 131 ++++++++++++++++ cmd/cfex/common.go | 141 +++++++++++++++++ cmd/cfex/delete.go | 126 +++++++++++++++ cmd/cfex/delete_test.go | 105 +++++++++++++ cmd/cfex/diag.go | 180 +++++++++++++++++++++ cmd/cfex/legacy.go | 260 +++++++++++++++++++++++++++++++ cmd/cfex/list.go | 233 +++++++++++++++++++++++++++ cmd/cfex/main.go | 94 +++++++++++ cmd/cfex/main_test.go | 113 ++++++++++++++ cmd/cfex/service.go | 170 ++++++++++++++++++++ cmd/cfex/stopstart.go | 92 +++++++++++ config.example.yaml | 32 ++++ go.mod | 5 + go.sum | 4 + internal/cf/cf.go | 155 ++++++++++++++++++ internal/cfg/cfg.go | 154 ++++++++++++++++++ internal/cfg/cfg_test.go | 43 +++++ internal/disc/disc.go | 142 +++++++++++++++++ internal/ingress/ingress.go | 102 ++++++++++++ internal/ingress/ingress_test.go | 61 ++++++++ 24 files changed, 2397 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 cmd/cfex/add.go create mode 100644 cmd/cfex/common.go create mode 100644 cmd/cfex/delete.go create mode 100644 cmd/cfex/delete_test.go create mode 100644 cmd/cfex/diag.go create mode 100644 cmd/cfex/legacy.go create mode 100644 cmd/cfex/list.go create mode 100644 cmd/cfex/main.go create mode 100644 cmd/cfex/main_test.go create mode 100644 cmd/cfex/service.go create mode 100644 cmd/cfex/stopstart.go create mode 100644 config.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cf/cf.go create mode 100644 internal/cfg/cfg.go create mode 100644 internal/cfg/cfg_test.go create mode 100644 internal/disc/disc.go create mode 100644 internal/ingress/ingress.go create mode 100644 internal/ingress/ingress_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..32e14f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: ci +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: '1.22' } + - run: make test + - run: make build diff --git a/.gitignore b/.gitignore index 79b8e25..cc7aaac 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ examples/**/out/ *.tmp *.temp .cache/ +/cfex diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a428c2d --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: build test install +build: + go build -o cfex ./cmd/cfex +# The suite is fail-closed: it refuses to run unless CFEX_CONFIG is under the temp dir. +test: + go vet ./... + CFEX_CONFIG=$$(mktemp -d)/config.yaml go test -count=1 ./... +install: + go install ./cmd/cfex diff --git a/README.md b/README.md index 3b49a73..ba25d1e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,39 @@ # cfex - Cloudflare Exposer CLI [![Discord](https://img.shields.io/badge/AgentNexus-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/V9C2kvHC8D) +> **v2 (Go)** adds a durable mode and a machine-wide view on top of everything v1 does. All v1 commands +> (`cfex host:3000`, `cfex host 3000`, `--https`, `--verify-cert`, `cfex list`, `cfex delete host`) keep working. +> The v1 bash script is still in `bin/cfex` until the Go binary is released. + +```bash +cfex dev.example.com:3000 # v1: foreground tunnel, Ctrl+C stops it +cfex add 3000 dev.example.com # v2: durable route on cfex's own tunnel, survives reboots (launchd / systemd) +cfex list # EVERYTHING on this machine, with live HTTP status +cfex stop dev.example.com # stop serving, keep config + DNS (cfex start to resume) +cfex delete dev.example.com # dry run; --yes to apply +cfex status | doctor | import +``` + +### What v2 adds +- **`add`**: one named tunnel owned by cfex; each route adds ONE ingress rule (a pure, unit-tested merge that never touches other + rules) and a proxied CNAME stamped `managed-by: cfex`. The connector runs under launchd (macOS) or a systemd user unit (Linux). +- **`list`**: managed routes plus everything else cloudflared-ish on the machine: account tunnels with their ingress rules, launchd + jobs, `~/.cloudflared` scripts, live HTTP status. Classes: `managed`, `PROTECTED`, `client`, `adopted`, `legacy` (v1), `unmanaged`. +- **Safety**: only routes cfex created can be stopped/deleted. `delete` is a dry run unless `--yes` and refuses any DNS record without + the `managed-by: cfex` comment. List tunnels you never want touched under `protected:` / `client:` in the config. +- **Secrets**: the API token is read from `CLOUDFLARE_API_TOKEN` only (or `token_cmd` runs cfex under your secret manager); it is never + printed or written to disk. The connector token is a 0600 file, or lives in your secret manager (`connector_run_cmd`). +- `import` adopts an existing tunnel into the config without changing what it serves. `doctor` checks cloudflared, the token, the + service, DNS <-> ingress consistency and lists plaintext token files by name. + +Config: `~/.config/cfex/config.yaml` (see `config.example.yaml`). Token permissions: Zone:Read, DNS:Edit, Account: Cloudflare Tunnel:Edit. +Build: `make build` · test: `make test` (the suite refuses to run unless `CFEX_CONFIG` points under the temp dir). + +Behaviour differences from v1: replacing an orphaned DNS record now only happens when it is a tunnel CNAME (v1 deleted any record +of that name); `cfex list` without an API token falls back to the v1 view. + +--- + Expose your local services to the internet using your domain names via Cloudflare's tunneling technology. With cfex, you can create instant HTTPS endpoints for any local service without port forwarding or static IPs. ## What is cfex? diff --git a/cmd/cfex/add.go b/cmd/cfex/add.go new file mode 100644 index 0000000..87a0252 --- /dev/null +++ b/cmd/cfex/add.go @@ -0,0 +1,131 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strconv" + "time" + + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +func cmdAdd(c *cfg.Config, args []string) error { + prot := false + pos := []string{} + for _, a := range args { + if a == "--protected" { + prot = true + } else { + pos = append(pos, a) + } + } + if len(pos) != 2 { + return fmt.Errorf("usage: cfex add [--protected]") + } + port, err := strconv.Atoi(pos[0]) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("bad port %q", pos[0]) + } + host, err := normHost(c, pos[1]) + if err != nil { + return err + } + cl, err := client(c) + if err != nil { + return err + } + acct, err := pickAccount(c, cl) + if err != nil { + return err + } + c.AccountID = acct + if c.Route(host) != nil { + return fmt.Errorf("%s is already a cfex route (use stop/start/delete)", host) + } + zones, err := cl.Zones() + if err != nil { + return err + } + z, err := zoneFor(zones, host) + if err != nil { + return err + } + if ex, err := cl.DNSByName(z.ID, host); err != nil { + return err + } else if len(ex) > 0 { + return fmt.Errorf("%s already has a %s DNS record that cfex did not create: refusing to touch it", host, ex[0].Type) + } + if !listening(port) { + fmt.Fprintf(os.Stderr, "warning: nothing is listening on 127.0.0.1:%d yet\n", port) + } + if err := ensureTunnel(c, cl); err != nil { + return err + } + full, rules, err := cl.TunnelConfig(acct, c.TunnelID) + if err != nil { + return err + } + next, err := ingress.Add(rules, host, "http://127.0.0.1:"+strconv.Itoa(port)) + if err != nil { + return err + } + rec, err := cl.DNSCreate(z.ID, cf.DNS{Type: "CNAME", Name: host, Content: c.TunnelID + ".cfargotunnel.com", Proxied: true, Comment: comment}) + if err != nil { + return err + } + if err := cl.PutIngress(acct, c.TunnelID, full, next); err != nil { + _ = cl.DNSDelete(z.ID, rec.ID) // roll back the record we just made + return err + } + c.Routes = append(c.Routes, cfg.Route{Host: host, Port: port, Zone: z.Name, ZoneID: z.ID, DNSID: rec.ID, + Protected: prot, State: "running", Created: time.Now().Format(time.RFC3339), TunnelID: c.TunnelID}) + if err := c.Save(); err != nil { + return err + } + fmt.Printf("routed https://%s -> 127.0.0.1:%d on tunnel %s\n", host, port, c.TunnelName) + st := "" + for i := 0; i < 24; i++ { // DNS + edge can take a little while + if st = httpStatus("https://" + host); st != "err" && st != "530" && st != "404" && st != "502" { + break + } + time.Sleep(5 * time.Second) + } + fmt.Printf("live check: https://%s -> HTTP %s\n", host, st) + return nil +} + +// ensureTunnel creates cfex's OWN named tunnel (and its service) on first use. +func ensureTunnel(c *cfg.Config, cl *cf.Client) error { + fresh := false + if c.TunnelID == "" { + ts, err := cl.Tunnels(c.AccountID) + if err != nil { + return err + } + for _, t := range ts { + if t.Name == c.TunnelName { + c.TunnelID = t.ID + } + } + if c.TunnelID == "" { + sec := make([]byte, 32) + rand.Read(sec) + t, err := cl.CreateTunnel(c.AccountID, c.TunnelName, base64.StdEncoding.EncodeToString(sec)) + if err != nil { + return err + } + c.TunnelID, fresh = t.ID, true + } + if err := c.Save(); err != nil { + return err + } + } + if err := storeToken(c, cl, fresh); err != nil { + return err + } + return installService(c) +} diff --git a/cmd/cfex/common.go b/cmd/cfex/common.go new file mode 100644 index 0000000..2d346ed --- /dev/null +++ b/cmd/cfex/common.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "path" + "strings" + "time" + + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +// comment is stamped on every DNS record cfex creates; delete refuses records without it. +const comment = "managed-by: cfex" + +// API is the slice of the Cloudflare client the mutating commands need (a fake in tests). +type API interface { + DNSGet(zone, id string) (cf.DNS, error) + DNSDelete(zone, id string) error + TunnelConfig(acct, tid string) (map[string]any, []ingress.Rule, error) + PutIngress(acct, tid string, full map[string]any, rules []ingress.Rule) error +} + +func matchAny(patterns []string, name string) bool { + for _, p := range patterns { + if ok, _ := path.Match(p, name); ok { + return true + } + } + return false +} + +// classify says whether a tunnel may be touched. Only "managed" and (via confirmation) "legacy" can be mutated. +func classify(c *cfg.Config, tunnel, id string) string { + switch { + case tunnel == c.TunnelName || (id != "" && id == c.TunnelID): + return "managed" + case matchAny(c.Protected, tunnel): + return "PROTECTED" + case matchAny(c.Client, tunnel): + return "client" + } + for _, a := range c.Adopted { + if a.Tunnel == tunnel { + return "adopted" + } + } + if strings.HasPrefix(tunnel, "tunnel_") { + return "legacy" // created by cfex v1 (`cfex host:port`) + } + return "unmanaged" +} + +func normHost(c *cfg.Config, h string) (string, error) { + h = strings.ToLower(strings.TrimSpace(h)) + if !strings.Contains(h, ".") { + if c.DefaultZone == "" { + return "", fmt.Errorf("%q is not a full hostname and no default_zone is set in %s", h, cfg.Path()) + } + h += "." + c.DefaultZone + } + return h, nil +} + +func zoneFor(zs []cf.Zone, host string) (cf.Zone, error) { + best := cf.Zone{} + for _, z := range zs { + if (host == z.Name || strings.HasSuffix(host, "."+z.Name)) && len(z.Name) > len(best.Name) { + best = z + } + } + if best.ID == "" { + return best, fmt.Errorf("%s is not in any Cloudflare zone this token can see", host) + } + return best, nil +} + +func httpStatus(url string) string { + // Resolve through a public resolver: the local one caches NXDOMAIN for a name queried just before it was created. + res := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, n, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, n, "1.1.1.1:53") + }} + tr := &http.Transport{DialContext: (&net.Dialer{Timeout: 5 * time.Second, Resolver: res}).DialContext} + cl := &http.Client{Transport: tr, Timeout: 6 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := cl.Get(url) + if err != nil { + return "err" + } + resp.Body.Close() + return fmt.Sprint(resp.StatusCode) +} + +func portOf(service string) string { + if i := strings.LastIndex(service, ":"); i >= 0 && strings.HasPrefix(service, "http") { + return service[i+1:] + } + return service +} + +func listening(port int) bool { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second) + if err != nil { + return false + } + c.Close() + return true +} + +func client(c *cfg.Config) (*cf.Client, error) { + t := token(c) + if t == "" { + return nil, fmt.Errorf("no Cloudflare API token: set CLOUDFLARE_API_TOKEN (create one with Zone:DNS:Edit, Zone:Read and Account:Cloudflare Tunnel:Edit) or token_cmd in %s", cfg.Path()) + } + return cf.New(t), nil +} + +// pickAccount uses account_id from config; otherwise the token's only account. Several accounts: the user must choose. +func pickAccount(c *cfg.Config, cl *cf.Client) (string, error) { + if c.AccountID != "" { + return c.AccountID, nil + } + as, err := cl.Accounts() + if err != nil { + return "", err + } + switch len(as) { + case 0: + return "", fmt.Errorf("token sees no Cloudflare account") + case 1: + return as[0].ID, nil + } + names := []string{} + for _, a := range as { + names = append(names, a.Name+" ("+a.ID+")") + } + return "", fmt.Errorf("token sees several accounts, set account_id in %s: %s", cfg.Path(), strings.Join(names, ", ")) +} diff --git a/cmd/cfex/delete.go b/cmd/cfex/delete.go new file mode 100644 index 0000000..86e1f17 --- /dev/null +++ b/cmd/cfex/delete.go @@ -0,0 +1,126 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +func cmdDelete(c *cfg.Config, args []string) error { + fs := flag.NewFlagSet("delete", flag.ExitOnError) + yes := fs.Bool("yes", false, "apply without asking (durable routes: default is a dry run)") + force := fs.Bool("force", false, "required to delete a PROTECTED managed route") + hosts := []string{} + rest := []string{} + for _, a := range args { + if strings.HasPrefix(a, "-") { + rest = append(rest, a) + } else { + hosts = append(hosts, a) + } + } + fs.Parse(rest) + if len(hosts) == 0 { + return fmt.Errorf("usage: cfex delete ... [--yes]") + } + var legacy []string + for _, h := range hosts { + host, err := normHost(c, h) + if err != nil { + return err + } + if c.Route(host) == nil { // not a durable route: maybe a cfex v1 tunnel + legacy = append(legacy, host) + continue + } + if err := protectedGuard(c, host, *force); err != nil { + return err + } + cl, err := client(c) + if err != nil { + return err + } + acct, err := pickAccount(c, cl) + if err != nil { + return err + } + if err := deleteRoute(cl, c, acct, host, *yes, os.Stdout); err != nil { + return err + } + } + if len(legacy) > 0 { + return legacyDelete(c, legacy, *yes, bufio.NewReader(os.Stdin)) + } + return nil +} + +func protectedGuard(c *cfg.Config, host string, force bool) error { + if r := c.Route(host); r != nil && r.Protected && !force { + return fmt.Errorf("%s is a protected route: deleting it needs --force", r.Host) + } + return nil +} + +// deleteRoute removes the ingress rule + the DNS record of a durable (managed) route. It refuses anything cfex did +// not create (not in config, or a DNS record without the managed-by comment). +func deleteRoute(api API, c *cfg.Config, acct, host string, apply bool, out io.Writer) error { + r := c.Route(host) + if r == nil { + return fmt.Errorf("%s is not a cfex-managed route: refusing (cfex only deletes what it created)", host) + } + if r.DNSID != "" { + rec, err := api.DNSGet(r.ZoneID, r.DNSID) + if err != nil { + return fmt.Errorf("cannot verify DNS record: %w", err) + } + if !strings.Contains(rec.Comment, comment) { + return fmt.Errorf("DNS record %s lacks the %q comment: refusing to delete", host, comment) + } + } + full, rules, err := api.TunnelConfig(acct, r.TunnelID) + if err != nil { + return err + } + has := ingress.Has(rules, host) + mode := "DRY RUN" + if apply { + mode = "APPLYING" + } + fmt.Fprintf(out, "%s: delete %s\n", mode, host) + if has { + fmt.Fprintf(out, " - remove ingress rule %s -> 127.0.0.1:%d on tunnel %s\n", host, r.Port, c.TunnelName) + } else { + fmt.Fprintf(out, " - ingress rule already absent (route is stopped)\n") + } + fmt.Fprintf(out, " - delete DNS record %s (id %s, managed-by cfex)\n", host, r.DNSID) + if !apply { + fmt.Fprintln(out, "dry run only; re-run with --yes to apply") + return nil + } + if has { + next, err := ingress.Remove(rules, host) + if err != nil { + return err + } + if err := api.PutIngress(acct, r.TunnelID, full, next); err != nil { + return err + } + } + if r.DNSID != "" { + if err := api.DNSDelete(r.ZoneID, r.DNSID); err != nil { + return err + } + } + c.DropRoute(host) + if err := c.Save(); err != nil { + return err + } + fmt.Fprintln(out, "deleted.") + return nil +} diff --git a/cmd/cfex/delete_test.go b/cmd/cfex/delete_test.go new file mode 100644 index 0000000..9309dd4 --- /dev/null +++ b/cmd/cfex/delete_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +type fake struct { + dns cf.DNS + rules []ingress.Rule + puts int + deleted []string +} + +func (f *fake) DNSGet(z, id string) (cf.DNS, error) { return f.dns, nil } +func (f *fake) DNSDelete(z, id string) error { f.deleted = append(f.deleted, id); return nil } +func (f *fake) TunnelConfig(a, t string) (map[string]any, []ingress.Rule, error) { + return map[string]any{}, f.rules, nil +} +func (f *fake) PutIngress(a, t string, full map[string]any, r []ingress.Rule) error { + f.puts++ + f.rules = r + return nil +} + +func setup(t *testing.T) (*cfg.Config, *fake) { + t.Setenv("CFEX_CONFIG", t.TempDir()+"/config.yaml") + c := cfg.Default() + c.Routes = []cfg.Route{{Host: "t.example.com", Port: 9, ZoneID: "z", DNSID: "d1", TunnelID: "T", State: "running"}} + f := &fake{dns: cf.DNS{Comment: comment}, rules: []ingress.Rule{ + {"hostname": "other.example.com", "service": "http://127.0.0.1:1"}, + {"hostname": "t.example.com", "service": "http://127.0.0.1:9"}, + {"service": ingress.CatchAll}}} + return c, f +} + +func TestDeleteDryRunChangesNothing(t *testing.T) { + c, f := setup(t) + var out bytes.Buffer + if err := deleteRoute(f, c, "a", "t.example.com", false, &out); err != nil { + t.Fatal(err) + } + if f.puts != 0 || len(f.deleted) != 0 || c.Route("t.example.com") == nil { + t.Fatal("dry run mutated something") + } + if !strings.Contains(out.String(), "DRY RUN") { + t.Fatal(out.String()) + } +} + +func TestDeleteRefusesUnmanagedHost(t *testing.T) { + c, f := setup(t) + if err := deleteRoute(f, c, "a", "os.example.com", true, &bytes.Buffer{}); err == nil || f.puts != 0 || len(f.deleted) != 0 { + t.Fatalf("must refuse a route cftunnel did not create: %v", err) + } +} + +func TestDeleteRefusesDNSWithoutManagedComment(t *testing.T) { + c, f := setup(t) + f.dns.Comment = "someone else's" + if err := deleteRoute(f, c, "a", "t.example.com", true, &bytes.Buffer{}); err == nil || len(f.deleted) != 0 { + t.Fatalf("must refuse DNS record lacking the comment: %v", err) + } +} + +func TestDeleteApplyLeavesOtherRoutes(t *testing.T) { + c, f := setup(t) + if err := deleteRoute(f, c, "a", "t.example.com", true, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if len(f.deleted) != 1 || ingress.Has(f.rules, "t.example.com") || !ingress.Has(f.rules, "other.example.com") { + t.Fatalf("bad result: %v %v", f.deleted, f.rules) + } +} + +func TestClassifyProtected(t *testing.T) { + c := cfg.Default() + c.Protected = []string{"billing", "pay-*"} + c.Client = []string{"acme-demo"} + for _, n := range []string{"billing", "pay-gateway"} { + if classify(c, n, "") != "PROTECTED" { + t.Fatal(n) + } + } + if classify(c, "acme-demo", "") != "client" || classify(c, "random", "") != "unmanaged" || + classify(c, "tunnel_dev_example_com", "") != "legacy" || classify(c, c.TunnelName, "") != "managed" { + t.Fatal("classes") + } + if classify(cfg.Default(), "billing", "") != "unmanaged" { + t.Fatal("nothing is protected unless the user's config says so") + } +} + +func TestProtectedRouteNeedsCeoGo(t *testing.T) { + c, _ := setup(t) + c.Routes[0].Protected = true + if err := protectedGuard(c, "t.example.com", false); err == nil { + t.Fatal("protected route must need --ceo-go") + } +} diff --git a/cmd/cfex/diag.go b/cmd/cfex/diag.go new file mode 100644 index 0000000..8e97998 --- /dev/null +++ b/cmd/cfex/diag.go @@ -0,0 +1,180 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/disc" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +func cmdStatus(c *cfg.Config, args []string) error { + fmt.Printf("tunnel %s (%s)\n", c.TunnelName, orDash(c.TunnelID)) + fmt.Printf("service %s loaded=%v\n", c.ServiceLabel, serviceLoaded(c)) + n := 0 + for _, r := range c.Routes { + if r.State == "stopped" { + n++ + } + } + fmt.Printf("routes %d (%d stopped)\n", len(c.Routes), n) + fmt.Printf("cloudflared %d processes on this machine\n", disc.RunningCloudflaredCount()) + if c.TunnelID != "" { + if cl, err := client(c); err == nil { + if acct, err := pickAccount(c, cl); err == nil { + if ts, err := cl.Tunnels(acct); err == nil { + for _, t := range ts { + if t.ID == c.TunnelID { + fmt.Printf("api %s, %d connections\n", t.Status, len(t.Connections)) + } + } + } + } + } + } + return nil +} + +func orDash(s string) string { + if s == "" { + return "not created yet" + } + return s +} + +func cmdDoctor(c *cfg.Config, args []string) error { + bad := 0 + check := func(ok bool, what, detail string) { + m := "ok " + if !ok { + m = "FAIL" + bad++ + } + fmt.Printf("[%s] %s %s\n", m, what, detail) + } + if p, err := exec.LookPath("cloudflared"); err == nil { + v, _ := exec.Command(p, "--version").Output() + check(true, "cloudflared installed", strings.TrimSpace(string(v))) + } else { + check(false, "cloudflared installed", "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") + } + cl, cerr := client(c) + if cerr != nil { + check(false, "Cloudflare API token available", cerr.Error()) + return fmt.Errorf("%d check(s) failed", bad) + } + check(true, "Cloudflare API token available", "") + acct, aerr := pickAccount(c, cl) + check(aerr == nil, "Cloudflare account reachable", fmt.Sprint(aerr)) + if c.TunnelID == "" { + fmt.Println("[info] cfex's own tunnel is created on the first `cfex add`") + } else if aerr == nil { + check(serviceLoaded(c), "service "+c.ServiceLabel+" running", "") + _, rules, err := cl.TunnelConfig(acct, c.TunnelID) + check(err == nil, "ingress readable", fmt.Sprint(err)) + zs, _ := cl.Zones() + for _, r := range c.Routes { + if r.State == "stopped" { + continue + } + svc, has := ingress.Service(rules, r.Host) + check(has, "ingress rule for "+r.Host, svc) + if z, err := zoneFor(zs, r.Host); err == nil { + recs, _ := cl.DNSByName(z.ID, r.Host) + check(len(recs) == 1 && strings.HasPrefix(recs[0].Content, c.TunnelID), "DNS -> tunnel for "+r.Host, "") + } + } + } + _, toks := disc.Scripts() + if len(toks) > 0 { + fmt.Printf("[warn] %d plaintext token files in ~/.cloudflared (names only): %s\n consider moving them into a secret manager.\n", len(toks), strings.Join(toks, " ")) + } + if bad > 0 { + return fmt.Errorf("%d check(s) failed", bad) + } + return nil +} + +// cmdImport adopts an existing tunnel into config WITHOUT changing what it serves: read-only discovery, a report, +// then a config record (config only; no API write, no service change). +func cmdImport(c *cfg.Config, args []string) error { + fs := flag.NewFlagSet("import", flag.ExitOnError) + dry := fs.Bool("dry-run", false, "report only; do not record") + target := "" + rest := []string{} + for _, a := range args { + if !strings.HasPrefix(a, "-") && target == "" { + target = a + } else { + rest = append(rest, a) + } + } + fs.Parse(rest) + if target == "" { + return fmt.Errorf("usage: cfex import [--dry-run]") + } + cl, err := client(c) + if err != nil { + return err + } + acct, err := pickAccount(c, cl) + if err != nil { + return err + } + ts, err := cl.Tunnels(acct) + if err != nil { + return err + } + base := strings.TrimSuffix(strings.TrimSuffix(target[strings.LastIndex(target, "/")+1:], ".sh"), "-tunnel") + var job *disc.Job + jobs := disc.Jobs() + for i, j := range jobs { + if (j.Script != "" && strings.Contains(j.Script, base+"-tunnel.sh")) || j.Label == target { + job = &jobs[i] + } + } + idx := -1 + for i, t := range ts { + if t.Name == target || (job != nil && job.TunnelID == t.ID) || t.Name == base || t.Name == base+"-demo" { + idx = i + } + } + if idx < 0 { + return fmt.Errorf("no account tunnel matches %q (use the tunnel name from `cfex list`)", target) + } + found := ts[idx] + _, rules, _ := cl.TunnelConfig(acct, found.ID) + fmt.Printf("tunnel %s (%s) status=%s class=%s\n", found.Name, found.ID, found.Status, classify(c, found.Name, found.ID)) + for _, r := range rules { + if h, _ := r["hostname"].(string); h != "" { + fmt.Printf("route %s -> %v\n", h, r["service"]) + } + } + a := cfg.Adopted{Tunnel: found.Name, TunnelID: found.ID} + if job != nil { + a.Label, a.Script, a.TokenFile = job.Label, job.Script, job.TokenFile + fmt.Printf("service %s loaded=%v (token file NAME: %s)\n", job.Label, job.Loaded, job.TokenFile) + } else { + fmt.Println("service no launchd job found for this tunnel") + } + if *dry { + fmt.Println("dry run: nothing recorded") + return nil + } + for _, x := range c.Adopted { + if x.TunnelID == a.TunnelID { + fmt.Println("already adopted") + return nil + } + } + c.Adopted = append(c.Adopted, a) + if err := c.Save(); err != nil { + return err + } + fmt.Fprintln(os.Stdout, "adopted into config; serving is unchanged. Protected/client classes still apply.") + return nil +} diff --git a/cmd/cfex/legacy.go b/cmd/cfex/legacy.go new file mode 100644 index 0000000..6680b4a --- /dev/null +++ b/cmd/cfex/legacy.go @@ -0,0 +1,260 @@ +package main + +import ( + "bufio" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfg" +) + +// cfex v1 behaviour: one cloudflared-managed tunnel per hostname, run in the foreground until Ctrl+C. +// `cfex host:3000`, `cfex host 3000`, --https, --verify-cert, `cfex list`, `cfex delete host` keep working. + +type legacyArgs struct { + Host string + Port int + HTTPS bool + VerifyCrt bool +} + +func legacyParse(args []string) (legacyArgs, error) { + var a legacyArgs + pos := []string{} + for _, x := range args { + switch x { + case "--https": + a.HTTPS = true + case "--verify-cert": + a.VerifyCrt = true + default: + if strings.HasPrefix(x, "-") { + return a, fmt.Errorf("unknown option %s\n\n%s", x, usage) + } + pos = append(pos, x) + } + } + var port string + if len(pos) == 1 { + if m := regexp.MustCompile(`^([^:]+):([0-9]+)$`).FindStringSubmatch(pos[0]); m != nil { + a.Host, port = m[1], m[2] + } + } else if len(pos) == 2 { + a.Host, port = pos[0], pos[1] + } + if a.Host == "" { + return a, fmt.Errorf("invalid arguments\n\n%s", usage) + } + p, err := strconv.Atoi(port) + if err != nil { + return a, fmt.Errorf("local_port must be a number") + } + if p < 1 || p > 65535 { + return a, fmt.Errorf("local_port must be between 1 and 65535") + } + a.Port, a.Host = p, strings.ToLower(a.Host) + return a, nil +} + +func legacyName(host string) string { return "tunnel_" + strings.ReplaceAll(host, ".", "_") } + +func cloudflaredDir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".cloudflared") } + +// legacyConfig is the per-hostname cloudflared config v1 wrote (pure, unit-tested). +func legacyConfig(https bool, port int, tunnelID string, ignoreCert bool) string { + creds := filepath.Join(cloudflaredDir(), tunnelID+".json") + if !https { + return fmt.Sprintf("url: http://localhost:%d\ntunnel: %s\ncredentials-file: %s\n", port, tunnelID, creds) + } + s := fmt.Sprintf("url: https://localhost:%d\ntunnel: %s\ncredentials-file: %s\noriginRequest:\n", port, tunnelID, creds) + if ignoreCert { + s += " noTLSVerify: true\n" + } + return s + " disableChunkedEncoding: true\n" +} + +func detectHTTPS(port int) bool { + d := &net.Dialer{Timeout: 3 * time.Second} + conn, err := tls.DialWithDialer(d, "tcp", fmt.Sprintf("localhost:%d", port), &tls.Config{InsecureSkipVerify: true, ServerName: "localhost"}) + if err != nil { + return false + } + conn.Close() + return true +} + +type ltunnel struct{ ID, Name string } + +func localTunnels() ([]ltunnel, error) { + out, err := exec.Command("cloudflared", "tunnel", "list", "--output", "json").Output() + if err != nil { + return nil, fmt.Errorf("cloudflared tunnel list: %v (run `cloudflared tunnel login` once)", err) + } + var ts []ltunnel + if s := strings.TrimSpace(string(out)); s != "" && s != "null" { + var raw []struct{ ID, Name string } + if err := json.Unmarshal(out, &raw); err != nil { + return nil, err + } + for _, r := range raw { + ts = append(ts, ltunnel(r)) + } + } + return ts, nil +} + +func legacyRun(c *cfg.Config, args []string) error { + a, err := legacyParse(args) + if err != nil { + return err + } + if _, err := exec.LookPath("cloudflared"); err != nil { + return fmt.Errorf("cloudflared is not installed") + } + if _, err := os.Stat(filepath.Join(cloudflaredDir(), "cert.pem")); err != nil { + fmt.Println("cloudflared is not authenticated: opening Cloudflare login. Choose the domain you want to tunnel, then run this command again.") + cmd := exec.Command("cloudflared", "tunnel", "login") + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Run() + return fmt.Errorf("authenticate, then re-run") + } + cl, err := client(c) + if err != nil { + return err + } + zones, err := cl.Zones() + if err != nil { + return err + } + z, err := zoneFor(zones, a.Host) + if err != nil { + return err + } + name := legacyName(a.Host) + cfgFile := filepath.Join(cloudflaredDir(), strings.ReplaceAll(a.Host, ".", "_")+"_config.yml") + ts, err := localTunnels() + if err != nil { + return err + } + var id string + for _, t := range ts { + if t.Name == name { + id = t.ID + } + } + if id != "" { + fmt.Println("Found existing tunnel named", name) + exec.Command("cloudflared", "tunnel", "cleanup", name).Run() + } else { + recs, err := cl.DNSByName(z.ID, a.Host) + if err != nil { + return err + } + for _, r := range recs { // an orphan from an earlier tunnel: only tunnel CNAMEs are replaced + if r.Type != "CNAME" || !strings.HasSuffix(r.Content, ".cfargotunnel.com") { + return fmt.Errorf("%s already has a %s record that is not a tunnel: refusing to replace it", a.Host, r.Type) + } + fmt.Println("Removing orphaned tunnel DNS record for", a.Host) + if err := cl.DNSDelete(z.ID, r.ID); err != nil { + return err + } + } + fmt.Println("Creating new tunnel named", name) + out, err := exec.Command("cloudflared", "tunnel", "create", name).CombinedOutput() + id = regexp.MustCompile(`[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}`).FindString(string(out)) + if err != nil || id == "" { + return fmt.Errorf("failed to create tunnel or capture its id: %s", strings.TrimSpace(string(out))) + } + } + fmt.Println("Tunnel ID:", id) + https := a.HTTPS + if !https && detectHTTPS(a.Port) { + https = true + fmt.Printf("Detected HTTPS service on port %d: switching to HTTPS mode\n", a.Port) + } + if err := os.WriteFile(cfgFile, []byte(legacyConfig(https, a.Port, id, !a.VerifyCrt)), 0o600); err != nil { + return err + } + fmt.Println("Config written to", cfgFile) + if out, err := exec.Command("cloudflared", "tunnel", "route", "dns", name, a.Host).CombinedOutput(); err != nil { + fmt.Printf("Warning: DNS route creation failed (%s). Continuing; if the tunnel does not work, try again in a few minutes.\n", strings.TrimSpace(string(out))) + } + fmt.Printf("Your service will be accessible at https://%s\nPress Ctrl+C to stop the tunnel\n", a.Host) + bin, _ := exec.LookPath("cloudflared") + return syscall.Exec(bin, []string{"cloudflared", "tunnel", "--config", cfgFile, "run", name}, os.Environ()) +} + +// legacyDelete removes cfex v1 tunnels (and their tunnel DNS records), with the same confirmation v1 asked for. +func legacyDelete(c *cfg.Config, hosts []string, yes bool, in *bufio.Reader) error { + ts, err := localTunnels() + if err != nil { + return err + } + have := map[string]bool{} + for _, t := range ts { + have[t.Name] = true + } + fmt.Println("The following tunnels will be deleted:") + var todo []string + for _, h := range hosts { + if have[legacyName(h)] { + fmt.Println("-", h) + todo = append(todo, h) + } else { + fmt.Println("-", h, "(not found: neither a cfex route nor a cfex v1 tunnel; nothing will be touched)") + } + } + if len(todo) == 0 { + return fmt.Errorf("nothing to delete") + } + if !yes { + fmt.Printf("Are you sure you want to delete these %d tunnels? (y/N) ", len(todo)) + ans, _ := in.ReadString('\n') + if a := strings.ToLower(strings.TrimSpace(ans)); a != "y" && a != "yes" { + fmt.Println("Operation cancelled.") + return nil + } + } + var cl *cf.Client + if t := token(c); t != "" { + cl = cf.New(t) + } else { + fmt.Println("note: no API token, DNS records will be left for you to remove") + } + var zones []cf.Zone + if cl != nil { + zones, _ = cl.Zones() + } + for _, h := range todo { + name := legacyName(h) + fmt.Println("Processing deletion for", h) + exec.Command("cloudflared", "tunnel", "cleanup", name).Run() + if out, err := exec.Command("cloudflared", "tunnel", "delete", "-f", name).CombinedOutput(); err != nil { + fmt.Printf("Failed to delete tunnel for %s, skipping DNS cleanup: %s\n", h, strings.TrimSpace(string(out))) + continue + } + if cl != nil { + if z, err := zoneFor(zones, h); err == nil { + recs, _ := cl.DNSByName(z.ID, h) + for _, r := range recs { + if r.Type == "CNAME" && strings.HasSuffix(r.Content, ".cfargotunnel.com") { + cl.DNSDelete(z.ID, r.ID) + } + } + } + } + fmt.Println("Tunnel for", h, "has been deleted.") + } + return nil +} diff --git a/cmd/cfex/list.go b/cmd/cfex/list.go new file mode 100644 index 0000000..bf16f9a --- /dev/null +++ b/cmd/cfex/list.go @@ -0,0 +1,233 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strconv" + "strings" + "sync" + "text/tabwriter" + + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/disc" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +type Row struct { + Host string `json:"host"` + Port string `json:"port"` + Tunnel string `json:"tunnel"` + Class string `json:"class"` + Run string `json:"run"` + HTTP string `json:"http"` + Local string `json:"local"` // service (launchd label) on this machine, if any + Account string `json:"account"` +} + +func cmdList(c *cfg.Config, args []string) error { + fs := flag.NewFlagSet("list", flag.ExitOnError) + asJSON := fs.Bool("json", false, "machine-readable output") + noHTTP := fs.Bool("no-http", false, "skip live HTTP checks") + fs.Parse(args) + cl, err := client(c) + if err != nil { // v1 behaviour: no API token, just what cloudflared knows + fmt.Fprintln(os.Stderr, "note:", err, "\nshowing cloudflared's own tunnel list only (cfex v1 view)") + return legacyList() + } + accts, err := cl.Accounts() + if err != nil { + return err + } + jobs := disc.Jobs() + byID := map[string]disc.Job{} + for _, j := range jobs { + if j.TunnelID != "" { + byID[j.TunnelID] = j + } + } + var rows []Row + for _, a := range accts { + ts, err := cl.Tunnels(a.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "cfex: tunnels of %q: %v\n", a.Name, err) + continue + } + type res struct{ rules []ingress.Rule } + out := make([]res, len(ts)) + var wg sync.WaitGroup + for i := range ts { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, r, _ := cl.TunnelConfig(a.ID, ts[i].ID) + out[i] = res{r} + }(i) + } + wg.Wait() + for i, t := range ts { + class := classify(c, t.Name, t.ID) + run := "down" + if t.Status == "healthy" { + run = "running" + } else if t.Status == "degraded" { + run = "degraded" + } + local := "-" + j, ok := byID[t.ID] + if !ok { // no token-derived match: fall back to launchd label naming + for _, jj := range jobs { + if sn := disc.ShortName(jj.Label); jj.TunnelID == "" && jj.ForwardPort == "" && (sn == t.Name || sn+"-demo" == t.Name || sn == strings.TrimSuffix(t.Name, "-demo")) { + j, ok = jj, true + } + } + } + if ok { + local = j.Label + if !j.Loaded { + local += " (not loaded)" + } + if j.Disabled { + local += " (disabled)" + } + } + n := 0 + for _, r := range out[i].rules { + h, _ := r["hostname"].(string) + svc, _ := r["service"].(string) + if h == "" && strings.HasPrefix(svc, "http_status") { + continue + } + if h == "" { + h = "(catch-all)" + if class == "legacy" { // a v1 tunnel: its hostname is encoded in the name + h = strings.ReplaceAll(strings.TrimPrefix(t.Name, "tunnel_"), "_", ".") + } + } else if pth, _ := r["path"].(string); pth != "" { + h += pth + } + rows = append(rows, Row{h, portOf(svc), t.Name, class, run, "", local, a.Name}) + n++ + } + if n == 0 { + h := "(no routes)" + if class == "legacy" { + h = strings.ReplaceAll(strings.TrimPrefix(t.Name, "tunnel_"), "_", ".") + } + rows = append(rows, Row{h, "-", t.Name, class, run, "", local, a.Name}) + } + } + } + // managed routes that are stopped have no ingress rule: show them from config + for _, r := range c.Routes { + if r.State == "stopped" { + rows = append(rows, Row{r.Host, fmt.Sprint(r.Port), c.TunnelName, "managed", "stopped", "", "-", ""}) + } + } + for _, j := range jobs { // local jobs that matched no account tunnel + used := false + for _, r := range rows { + if strings.HasPrefix(r.Local, j.Label) { + used = true + } + } + if used { + continue + } + if j.ForwardPort != "" { // an ssh -L forward: judged by a live check, not by its log + p, _ := strconv.Atoi(j.ForwardPort) + run := "no answer" + if listening(p) { + run = "answers" + } + rows = append(rows, Row{"(ssh-forward)", j.ForwardPort, "-", classify(c, disc.ShortName(j.Label), ""), run, "-", j.Label, ""}) + continue + } + state, run := "not loaded", "stopped" + if j.Loaded { + state = "loaded, last exit " + j.LastExit + } + if j.Disabled { + run, state = "stale", "disabled plist" + } + rows = append(rows, Row{"(no matching tunnel)", "-", "-", classify(c, disc.ShortName(j.Label), ""), run, "-", j.Label + " (" + state + ")", ""}) + } + sort.SliceStable(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) + if !*noHTTP { + var wg sync.WaitGroup + sem := make(chan struct{}, 12) + for i := range rows { + if strings.HasPrefix(rows[i].Host, "(") || rows[i].Host == "*" || rows[i].HTTP != "" { + if rows[i].HTTP == "" { + rows[i].HTTP = "-" + } + continue + } + wg.Add(1) + go func(i int) { + defer wg.Done() + sem <- struct{}{} + h := rows[i].Host + if k := strings.Index(h, "/"); k >= 0 { // path rule: check the hostname + h = h[:k] + } + rows[i].HTTP = httpStatus("https://" + h) + <-sem + }(i) + } + wg.Wait() + } else { + for i := range rows { + if rows[i].HTTP == "" { + rows[i].HTTP = "-" + } + } + } + if *asJSON { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"routes": rows, "cloudflared_processes": disc.RunningCloudflaredCount()}) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "HOST\tPORT\tTUNNEL\tCLASS\tRUN\tHTTP\tLOCAL SERVICE") + hostCount := map[string]int{} + for _, r := range rows { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Port, r.Tunnel, r.Class, r.Run, r.HTTP, r.Local) + hostCount[r.Host]++ + } + tw.Flush() + scripts, toks := disc.Scripts() + fmt.Printf("\n%d rows · %d cloudflared processes · %d launchd tunnel jobs · %d ~/.cloudflared scripts · %d plaintext token files (names only; `cfex doctor`)\n", + len(rows), disc.RunningCloudflaredCount(), len(jobs), len(scripts), len(toks)) + var dups []string + for h, n := range hostCount { + if n > 1 && !strings.HasPrefix(h, "(") && h != "*" { + dups = append(dups, h) + } + } + sort.Strings(dups) + if len(dups) > 0 { + fmt.Println("hostnames claimed by more than one rule/tunnel:", strings.Join(dups, ", ")) + } + return nil +} + +// legacyList prints cloudflared's own tunnels the way cfex v1 did. +func legacyList() error { + ts, err := localTunnels() + if err != nil { + return err + } + fmt.Println("Active Tunnels:\n----------------------------------------") + if len(ts) == 0 { + fmt.Println("No active tunnels found.") + } + for _, t := range ts { + n := t.Name + if strings.HasPrefix(n, "tunnel_") { + n = strings.ReplaceAll(strings.TrimPrefix(n, "tunnel_"), "_", ".") + } + fmt.Printf("%-40s %s\n", n, t.ID) + } + return nil +} diff --git a/cmd/cfex/main.go b/cmd/cfex/main.go new file mode 100644 index 0000000..ac5747e --- /dev/null +++ b/cmd/cfex/main.go @@ -0,0 +1,94 @@ +// cfex: expose a local port on your own domain through Cloudflare tunnels, and see/stop/start/delete +// every tunnel on the machine. +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + + "github.com/muthuishere/cfex-cli/internal/cfg" +) + +const version = "2.0.0" + +const usage = `cfex: expose a local port on your own domain through Cloudflare, and manage every tunnel on this machine. + + cfex : [--https] [--verify-cert] foreground tunnel, Ctrl+C stops it (cfex v1 behaviour, unchanged) + cfex same, original syntax + cfex add [--protected] durable route on cfex's own tunnel, kept running by launchd/systemd + cfex list [--json] [--no-http] EVERYTHING on this machine: managed, protected, client, legacy, unmanaged + cfex stop stop serving (keeps config + DNS) + cfex start serve again + cfex delete ... [--yes] durable route: dry run unless --yes; v1 tunnel: asks to confirm + cfex status cfex's own tunnel at a glance + cfex doctor cloudflared, token, service, DNS <-> ingress, plaintext token files + cfex import [--dry-run] adopt an existing tunnel into config WITHOUT changing what it serves + cfex -h | -v + +Token: CLOUDFLARE_API_TOKEN (CLOUDFLARE_API_KEY still accepted), or 'token_cmd' in the config. +Config: ~/.config/cfex/config.yaml · state: ~/.local/share/cfex/` + +func fatal(msg string) { fmt.Fprintln(os.Stderr, "cfex:", msg); os.Exit(1) } + +// withToken re-executes cfex under the configured token_cmd (e.g. "op run --") when no token is in the environment, +// so the token value only ever exists in the child's environment. +func withToken(c *cfg.Config) { + if token(c) != "" || os.Getenv("CFEX_WRAPPED") == "1" || strings.TrimSpace(c.TokenCmd) == "" { + return + } + prefix := strings.Fields(c.TokenCmd) + bin, err := exec.LookPath(prefix[0]) + if err != nil { + fatal("token_cmd: " + err.Error()) + } + self, _ := os.Executable() + args := append(append(prefix, self), os.Args[1:]...) + env := append(os.Environ(), "CFEX_WRAPPED=1") + fatal(syscall.Exec(bin, args, env).Error()) +} + +// token returns the API token from the environment only. +func token(c *cfg.Config) string { + for _, n := range []string{c.TokenEnv, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_KEY"} { + if n != "" && os.Getenv(n) != "" { + return os.Getenv(n) + } + } + return "" +} + +var subcommands = map[string]func(*cfg.Config, []string) error{ + "add": cmdAdd, "list": cmdList, "ls": cmdList, "stop": cmdStop, "start": cmdStart, + "delete": cmdDelete, "rm": cmdDelete, "status": cmdStatus, "doctor": cmdDoctor, "import": cmdImport, +} + +func main() { + if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" || os.Args[1] == "help" { + fmt.Println(usage) + return + } + if os.Args[1] == "-v" || os.Args[1] == "--version" { + fmt.Println("cfex version", version) + return + } + c, err := cfg.Load() + if err != nil { + fatal(err.Error()) + } + cmd, args := os.Args[1], os.Args[2:] + if f, ok := subcommands[cmd]; ok { + withToken(c) + if err := f(c, args); err != nil { + fatal(err.Error()) + } + return + } + // cfex v1 syntax: : or , foreground. + withToken(c) + if err := legacyRun(c, os.Args[1:]); err != nil { + fatal(err.Error()) + } +} diff --git a/cmd/cfex/main_test.go b/cmd/cfex/main_test.go new file mode 100644 index 0000000..1ed7ead --- /dev/null +++ b/cmd/cfex/main_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/muthuishere/cfex-cli/internal/cfg" +) + +// The suite refuses to run unless CFEX_CONFIG points under the temp dir (fail-closed). +func TestMain(m *testing.M) { + p := os.Getenv("CFEX_CONFIG") + if p == "" || !cfg.UnderTemp(p) { + fmt.Fprintln(os.Stderr, "refusing to run tests: CFEX_CONFIG must point under the temp dir (use `make test`)") + os.Exit(2) + } + os.Exit(m.Run()) +} + +func TestLegacyParse(t *testing.T) { + for _, tc := range []struct { + args []string + host string + port int + https bool + ok bool + }{ + {[]string{"dev.example.com:3000"}, "dev.example.com", 3000, false, true}, + {[]string{"dev.example.com", "8080"}, "dev.example.com", 8080, false, true}, + {[]string{"dev.example.com:8443", "--https", "--verify-cert"}, "dev.example.com", 8443, true, true}, + {[]string{"--https", "Dev.Example.com:1"}, "dev.example.com", 1, true, true}, + {[]string{"dev.example.com:0"}, "", 0, false, false}, + {[]string{"dev.example.com:70000"}, "", 0, false, false}, + {[]string{"justahost"}, "", 0, false, false}, + {[]string{"a.b", "x"}, "", 0, false, false}, + {[]string{"--bogus", "a.b:1"}, "", 0, false, false}, + } { + a, err := legacyParse(tc.args) + if (err == nil) != tc.ok { + t.Fatalf("%v: err=%v", tc.args, err) + } + if tc.ok && (a.Host != tc.host || a.Port != tc.port || a.HTTPS != tc.https) { + t.Fatalf("%v: got %+v", tc.args, a) + } + } +} + +func TestLegacyConfigMatchesV1(t *testing.T) { + http := legacyConfig(false, 3000, "ID", true) + if !strings.Contains(http, "url: http://localhost:3000\ntunnel: ID\n") || strings.Contains(http, "originRequest") { + t.Fatal(http) + } + if s := legacyConfig(true, 8443, "ID", true); !strings.Contains(s, "url: https://localhost:8443") || !strings.Contains(s, "noTLSVerify: true") || !strings.Contains(s, "disableChunkedEncoding: true") { + t.Fatal(s) + } + if s := legacyConfig(true, 8443, "ID", false); strings.Contains(s, "noTLSVerify") { + t.Fatal("--verify-cert must not disable verification") + } + if legacyName("dev.example.com") != "tunnel_dev_example_com" { + t.Fatal("v1 tunnel naming must be unchanged") + } +} + +func TestNormHost(t *testing.T) { + c := cfg.Default() + if _, err := normHost(c, "app"); err == nil { + t.Fatal("a bare name needs a default_zone") + } + c.DefaultZone = "example.com" + if h, _ := normHost(c, "App"); h != "app.example.com" { + t.Fatal(h) + } + if h, _ := normHost(c, "x.other.org"); h != "x.other.org" { + t.Fatal(h) + } +} + +func TestTokenFromEnvOnly(t *testing.T) { + c := cfg.Default() + for _, n := range []string{"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_KEY", "MY_TOK"} { + t.Setenv(n, "") + } + if token(c) != "" { + t.Fatal("no token expected") + } + t.Setenv("CLOUDFLARE_API_KEY", "legacy") + if token(c) != "legacy" { + t.Fatal("v1 CLOUDFLARE_API_KEY must still work") + } + c.TokenEnv = "MY_TOK" + t.Setenv("MY_TOK", "custom") + if token(c) != "custom" { + t.Fatal("token_env must win") + } +} + +func TestServiceUnitNeverContainsToken(t *testing.T) { + c := cfg.Default() + body := serviceBody(c, []string{"/usr/bin/cloudflared", "tunnel", "run", "--token-file", "/x/connector.token"}) + if strings.Contains(strings.ToLower(body), "eyj") || !strings.Contains(body, "connector.token") { + t.Fatal("unit must reference the token FILE, never embed a token") + } +} + +func TestOnlyManagedStopStartAllowed(t *testing.T) { + c := cfg.Default() + c.Protected = []string{"billing"} + if err := stopStart(c, []string{"billing"}, false); err == nil { + t.Fatal("must refuse a tunnel that is not a cfex route") + } +} diff --git a/cmd/cfex/service.go b/cmd/cfex/service.go new file mode 100644 index 0000000..e1857e5 --- /dev/null +++ b/cmd/cfex/service.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfg" +) + +// The durable connector for cfex's own tunnel runs as a launchd agent (macOS) or a systemd user unit (Linux). +// The connector token is never written into the unit: it is read from a 0600 file (default) or handed over +// by the configured secret manager at start time. + +func tokenFilePath() string { return filepath.Join(cfg.StateDir(), "connector.token") } + +func connectorArgs(c *cfg.Config) ([]string, error) { + cfd, err := exec.LookPath("cloudflared") + if err != nil { + return nil, fmt.Errorf("cloudflared not installed (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)") + } + if strings.TrimSpace(c.ConnectorRunCmd) == "" { + return []string{cfd, "tunnel", "--no-autoupdate", "run", "--token-file", tokenFilePath()}, nil + } + if c.ConnectorEnv == "" { + return nil, fmt.Errorf("connector_run_cmd needs connector_env (the variable it exports)") + } + pre := strings.Fields(c.ConnectorRunCmd) + bin, err := exec.LookPath(pre[0]) + if err != nil { + return nil, err + } + inner := fmt.Sprintf(`TUNNEL_TOKEN="$%s" exec %s tunnel --no-autoupdate run`, c.ConnectorEnv, cfd) + return append(append([]string{bin}, pre[1:]...), "/bin/sh", "-c", inner), nil +} + +// storeToken keeps the connector token where the service can read it, never printing it. +func storeToken(c *cfg.Config, cl *cf.Client, fresh bool) error { + if strings.TrimSpace(c.ConnectorRunCmd) == "" { + if _, err := os.Stat(tokenFilePath()); err == nil && !fresh { + return nil + } + tok, err := cl.TunnelToken(c.AccountID, c.TunnelID) + if err != nil { + return err + } + if err := os.MkdirAll(cfg.StateDir(), 0o700); err != nil { + return err + } + return os.WriteFile(tokenFilePath(), []byte(tok), 0o600) + } + if !fresh { + return nil + } + if strings.TrimSpace(c.ConnectorSetCmd) == "" { + return fmt.Errorf("connector_run_cmd is set but connector_set_cmd is not: cannot store the new connector token") + } + tok, err := cl.TunnelToken(c.AccountID, c.TunnelID) + if err != nil { + return err + } + f := strings.Fields(c.ConnectorSetCmd) + cmd := exec.Command(f[0], f[1:]...) + cmd.Stdin = strings.NewReader(tok) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("connector_set_cmd failed: %v: %s", err, strings.ReplaceAll(string(out), tok, "[redacted]")) + } + fmt.Println("connector token stored via connector_set_cmd") + return nil +} + +func plistPath(c *cfg.Config) string { + h, _ := os.UserHomeDir() + return filepath.Join(h, "Library", "LaunchAgents", c.ServiceLabel+".plist") +} +func unitPath(c *cfg.Config) string { + h, _ := os.UserHomeDir() + return filepath.Join(h, ".config", "systemd", "user", c.ServiceLabel+".service") +} +func domain() string { return "gui/" + strconv.Itoa(os.Getuid()) } + +func esc(s string) string { + var b strings.Builder + xml.EscapeText(&b, []byte(s)) + return b.String() +} + +func serviceBody(c *cfg.Config, args []string) string { + logp := filepath.Join(cfg.StateDir(), "cloudflared.log") + if runtime.GOOS == "darwin" { + var a strings.Builder + for _, x := range args { + a.WriteString("" + esc(x) + "") + } + return fmt.Sprintf(` + + +Label%s +ProgramArguments%s +RunAtLoadKeepAlive +StandardOutPath%sStandardErrorPath%s + +`, esc(c.ServiceLabel), a.String(), esc(logp), esc(logp)) + } + q := make([]string, len(args)) + for i, x := range args { + q[i] = "'" + strings.ReplaceAll(x, "'", `'\''`) + "'" + } + return fmt.Sprintf("[Unit]\nDescription=cfex tunnel connector\nAfter=network-online.target\n\n[Service]\nExecStart=%s\nRestart=always\nRestartSec=5\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=default.target\n", + strings.Join(q, " "), logp, logp) +} + +func serviceLoaded(c *cfg.Config) bool { + if runtime.GOOS == "darwin" { + return exec.Command("launchctl", "print", domain()+"/"+c.ServiceLabel).Run() == nil + } + return exec.Command("systemctl", "--user", "is-active", "--quiet", c.ServiceLabel+".service").Run() == nil +} + +func serviceStart(c *cfg.Config) error { + if runtime.GOOS == "darwin" { + _ = exec.Command("launchctl", "bootout", domain()+"/"+c.ServiceLabel).Run() + if out, err := exec.Command("launchctl", "bootstrap", domain(), plistPath(c)).CombinedOutput(); err != nil { + return fmt.Errorf("launchctl bootstrap: %v: %s", err, out) + } + return nil + } + exec.Command("systemctl", "--user", "daemon-reload").Run() + if out, err := exec.Command("systemctl", "--user", "enable", "--now", c.ServiceLabel+".service").CombinedOutput(); err != nil { + return fmt.Errorf("systemctl: %v: %s", err, out) + } + return nil +} + +func serviceStop(c *cfg.Config) error { + if runtime.GOOS == "darwin" { + return exec.Command("launchctl", "bootout", domain()+"/"+c.ServiceLabel).Run() + } + return exec.Command("systemctl", "--user", "stop", c.ServiceLabel+".service").Run() +} + +// installService writes the unit (idempotent) and starts it. +func installService(c *cfg.Config) error { + args, err := connectorArgs(c) + if err != nil { + return err + } + if err := os.MkdirAll(cfg.StateDir(), 0o700); err != nil { + return err + } + body := serviceBody(c, args) + p := plistPath(c) + if runtime.GOOS != "darwin" { + p = unitPath(c) + os.MkdirAll(filepath.Dir(p), 0o755) + } + if b, err := os.ReadFile(p); err == nil && string(b) == body && serviceLoaded(c) { + return nil + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + return err + } + return serviceStart(c) +} diff --git a/cmd/cfex/stopstart.go b/cmd/cfex/stopstart.go new file mode 100644 index 0000000..4ea7c0e --- /dev/null +++ b/cmd/cfex/stopstart.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/disc" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +func cmdStop(c *cfg.Config, args []string) error { return stopStart(c, args, false) } +func cmdStart(c *cfg.Config, args []string) error { return stopStart(c, args, true) } + +// forced: CFEX_FORCE=1 lets stop/start touch a protected managed route (a deliberate, explicit act). +var forced = func() bool { return os.Getenv("CFEX_FORCE") == "1" } + +// stopStart: a managed route is stopped by removing ONLY its ingress rule (DNS + config kept) and started by +// re-adding it; cfex's own tunnel via its service; adopted non-protected tunnels via their launchd job. +// Anything protected / client / unmanaged is refused. +func stopStart(c *cfg.Config, args []string, start bool) error { + verb := map[bool]string{true: "start", false: "stop"}[start] + if len(args) != 1 { + return fmt.Errorf("usage: cfex %s ", verb) + } + target := args[0] + if target == c.TunnelName { + if start { + return serviceStart(c) + } + return serviceStop(c) + } + host, herr := normHost(c, target) + if r := c.Route(host); herr == nil && r != nil { + if r.Protected && !forced() { + return fmt.Errorf("%s is a protected route: %s needs an explicit decision (set CFEX_FORCE=1)", host, verb) + } + cl, err := client(c) + if err != nil { + return err + } + acct, err := pickAccount(c, cl) + if err != nil { + return err + } + full, rules, err := cl.TunnelConfig(acct, r.TunnelID) + if err != nil { + return err + } + var next []ingress.Rule + if start { + next, err = ingress.Add(rules, host, fmt.Sprintf("http://127.0.0.1:%d", r.Port)) + if err == ingress.ErrExists { + r.State = "running" + return c.Save() + } + } else { + next, err = ingress.Remove(rules, host) + if err == ingress.ErrNotFound { + r.State = "stopped" + return c.Save() + } + } + if err != nil { + return err + } + if err := cl.PutIngress(acct, r.TunnelID, full, next); err != nil { + return err + } + r.State = map[bool]string{true: "running", false: "stopped"}[start] + fmt.Printf("%s: %s\n", host, r.State) + return c.Save() + } + for _, a := range c.Adopted { + if a.Tunnel == target || a.Label == target { + if cls := classify(c, a.Tunnel, a.TunnelID); cls != "adopted" { + return fmt.Errorf("%s is %s: refusing to %s", target, cls, verb) + } + for _, j := range disc.Jobs() { + if j.Label == a.Label { + if start { + return exec.Command("launchctl", "bootstrap", domain(), j.Plist).Run() + } + return exec.Command("launchctl", "bootout", domain()+"/"+j.Label).Run() + } + } + return fmt.Errorf("no launchd job found for adopted tunnel %s", a.Tunnel) + } + } + return fmt.Errorf("%q is not a cfex-managed route: refusing to %s. `cfex list` shows what is managed; protected, client and unmanaged tunnels are never modified", target, verb) +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e15dd6c --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,32 @@ +# ~/.config/cfex/config.yaml (every key is optional) + +# `cfex add 3000 app` means app.example.com. Without it, hosts must be fully qualified. +default_zone: example.com + +# Needed only if your token can see several Cloudflare accounts. +# account_id: 0123456789abcdef0123456789abcdef + +# The one tunnel cfex owns for durable routes (`cfex add`), and the launchd label / systemd unit that runs it. +tunnel_name: cfex +service_label: dev.cfex.tunnel + +# Where the API token comes from. By default the environment variable CLOUDFLARE_API_TOKEN +# (CLOUDFLARE_API_KEY, the cfex v1 name, also works). +# token_env: CLOUDFLARE_API_TOKEN +# Or run cfex under a secret manager so the token only exists in cfex's own environment: +# token_cmd: "op run --" # 1Password; the command must export token_env +# token_cmd: "sec run MY_CF_TOKEN --" # token_env: MY_CF_TOKEN + +# The durable tunnel's connector token is kept in a 0600 file under ~/.local/share/cfex by default. +# To keep it in a secret manager instead: +# connector_run_cmd: "sec run MY_TUNNEL_TOKEN --" +# connector_env: MY_TUNNEL_TOKEN +# connector_set_cmd: "sec set MY_TUNNEL_TOKEN" # receives the token on stdin + +# Tunnel-name globs cfex lists but NEVER modifies (stop/start/delete are refused). +protected: [] # e.g. ["billing", "prod-*"] +client: [] # e.g. ["acme-demo"]: shown as class "client" + +# Written by cfex: +routes: [] +adopted: [] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..192c860 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/muthuishere/cfex-cli + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cf/cf.go b/internal/cf/cf.go new file mode 100644 index 0000000..024f1f7 --- /dev/null +++ b/internal/cf/cf.go @@ -0,0 +1,155 @@ +// Package cf is a thin Cloudflare API client. The token is passed in by the caller (read from the +// environment only, never from a file or a flag). +package cf + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +const Base = "https://api.cloudflare.com/client/v4" + +type Client struct { + HTTP *http.Client + token string +} + +func New(token string) *Client { + return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}, token: token} +} + +func (c *Client) do(method, path string, body, out any) error { + tok := c.token + if tok == "" { + return fmt.Errorf("no Cloudflare API token (set CLOUDFLARE_API_TOKEN)") + } + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, Base+path, rd) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + var env struct { + Success bool `json:"success"` + Errors []struct { + Code int + Message string + } `json:"errors"` + Result json.RawMessage `json:"result"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("%s %s: http %d, unparseable body", method, path, resp.StatusCode) + } + if !env.Success { + msgs := []string{} + for _, e := range env.Errors { + msgs = append(msgs, fmt.Sprintf("%d %s", e.Code, e.Message)) + } + return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, strings.Join(msgs, "; ")) + } + if out != nil { + return json.Unmarshal(env.Result, out) + } + return nil +} + +type Account struct{ ID, Name string } +type Tunnel struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ConfigSrc string `json:"config_src"` + Connections []struct { + ID string `json:"id"` + } `json:"connections"` +} +type Zone struct{ ID, Name string } +type DNS struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Comment string `json:"comment"` + Proxied bool `json:"proxied"` +} + +func (c *Client) Accounts() (a []Account, err error) { return a, c.do("GET", "/accounts", nil, &a) } + +func (c *Client) Tunnels(acct string) (t []Tunnel, err error) { + return t, c.do("GET", "/accounts/"+acct+"/cfd_tunnel?is_deleted=false&per_page=100", nil, &t) +} + +func (c *Client) Zones() (z []Zone, err error) { return z, c.do("GET", "/zones?per_page=50", nil, &z) } + +// TunnelConfig returns the full remote config (so a PUT can round-trip fields we don't own) and its ingress. +func (c *Client) TunnelConfig(acct, tid string) (full map[string]any, rules []ingress.Rule, err error) { + var r struct { + Config map[string]any `json:"config"` + } + if err = c.do("GET", "/accounts/"+acct+"/cfd_tunnel/"+tid+"/configurations", nil, &r); err != nil { + return + } + full = r.Config + if full == nil { + full = map[string]any{} + } + if raw, ok := full["ingress"].([]any); ok { + for _, x := range raw { + if m, ok := x.(map[string]any); ok { + rules = append(rules, m) + } + } + } + return +} + +func (c *Client) PutIngress(acct, tid string, full map[string]any, rules []ingress.Rule) error { + cp := map[string]any{} + for k, v := range full { + cp[k] = v + } + cp["ingress"] = rules + return c.do("PUT", "/accounts/"+acct+"/cfd_tunnel/"+tid+"/configurations", map[string]any{"config": cp}, nil) +} + +func (c *Client) CreateTunnel(acct, name, secretB64 string) (t Tunnel, err error) { + return t, c.do("POST", "/accounts/"+acct+"/cfd_tunnel", map[string]any{ + "name": name, "config_src": "cloudflare", "tunnel_secret": secretB64}, &t) +} + +// TunnelToken returns the connector token. Callers must hand it straight to sec, never print it. +func (c *Client) TunnelToken(acct, tid string) (tok string, err error) { + return tok, c.do("GET", "/accounts/"+acct+"/cfd_tunnel/"+tid+"/token", nil, &tok) +} + +func (c *Client) DNSByName(zone, name string) (d []DNS, err error) { + return d, c.do("GET", "/zones/"+zone+"/dns_records?name="+url.QueryEscape(name), nil, &d) +} + +func (c *Client) DNSGet(zone, id string) (d DNS, err error) { + return d, c.do("GET", "/zones/"+zone+"/dns_records/"+id, nil, &d) +} + +func (c *Client) DNSCreate(zone string, d DNS) (out DNS, err error) { + return out, c.do("POST", "/zones/"+zone+"/dns_records", d, &out) +} + +func (c *Client) DNSDelete(zone, id string) error { + return c.do("DELETE", "/zones/"+zone+"/dns_records/"+id, nil, nil) +} diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go new file mode 100644 index 0000000..d4a3415 --- /dev/null +++ b/internal/cfg/cfg.go @@ -0,0 +1,154 @@ +// Package cfg is cfex's config file (~/.config/cfex/config.yaml) and state dir. +package cfg + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Route struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Zone string `yaml:"zone"` + ZoneID string `yaml:"zone_id"` + DNSID string `yaml:"dns_id"` + State string `yaml:"state"` // running | stopped + Created string `yaml:"created"` + TunnelID string `yaml:"tunnel_id"` + Protected bool `yaml:"protected,omitempty"` // managed, but never stopped/deleted without --force +} + +type Adopted struct { + Tunnel string `yaml:"tunnel"` + TunnelID string `yaml:"tunnel_id"` + Script string `yaml:"script,omitempty"` + Label string `yaml:"service_label,omitempty"` + TokenFile string `yaml:"token_file,omitempty"` // NAME only, never the value +} + +type Config struct { + // DefaultZone lets `cfex add 3000 app` mean app.. Empty: hosts must be fully qualified. + DefaultZone string `yaml:"default_zone,omitempty"` + AccountID string `yaml:"account_id,omitempty"` + // TunnelName is the one named tunnel cfex owns for durable routes (`cfex add`). + TunnelName string `yaml:"tunnel_name"` + TunnelID string `yaml:"tunnel_id,omitempty"` + + // TokenEnv is the environment variable holding the Cloudflare API token (default CLOUDFLARE_API_TOKEN; + // CLOUDFLARE_API_KEY is accepted for compatibility with cfex v1). + TokenEnv string `yaml:"token_env,omitempty"` + // TokenCmd is an optional command prefix that cfex re-executes itself under so the token only ever lives in + // the child's environment, e.g. "op run --" or "sec run CLOUDFLARE_TOKEN --". It must export TokenEnv. + TokenCmd string `yaml:"token_cmd,omitempty"` + + // ServiceLabel is the launchd label / systemd unit name of the connector for the durable tunnel. + ServiceLabel string `yaml:"service_label,omitempty"` + // Connector token handling for the durable tunnel. Default: a 0600 token file under the state dir. + // Or keep it in a secret manager: ConnectorRunCmd wraps the connector ("sec run MYTOKEN --"), ConnectorEnv + // names the variable it exports, ConnectorSetCmd stores a value read from stdin ("sec set MYTOKEN"). + ConnectorRunCmd string `yaml:"connector_run_cmd,omitempty"` + ConnectorEnv string `yaml:"connector_env,omitempty"` + ConnectorSetCmd string `yaml:"connector_set_cmd,omitempty"` + + Protected []string `yaml:"protected,omitempty"` // tunnel-name globs that cfex never modifies + Client []string `yaml:"client,omitempty"` // tunnel-name globs listed as "client": never modified + Routes []Route `yaml:"routes"` + Adopted []Adopted `yaml:"adopted"` +} + +func Default() *Config { + return &Config{TunnelName: "cfex", TokenEnv: "CLOUDFLARE_API_TOKEN", ServiceLabel: "dev.cfex.tunnel"} +} + +func Dir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".config", "cfex") } +func StateDir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".local", "share", "cfex") } +func Path() string { + if p := os.Getenv("CFEX_CONFIG"); p != "" { + return p + } + return filepath.Join(Dir(), "config.yaml") +} + +func Load() (*Config, error) { + c := Default() + b, err := os.ReadFile(Path()) + if os.IsNotExist(err) { + return c, nil + } + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(b, c); err != nil { + return nil, err + } + if c.TokenEnv == "" { + c.TokenEnv = "CLOUDFLARE_API_TOKEN" + } + if c.TunnelName == "" { + c.TunnelName = "cfex" + } + if c.ServiceLabel == "" { + c.ServiceLabel = "dev.cfex.tunnel" + } + return c, nil +} + +// UnderTemp reports whether path lives inside the OS temp dir (symlinks resolved). +func UnderTemp(path string) bool { + real := func(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + return p + } + rel, err := filepath.Rel(real(os.TempDir()), real(filepath.Dir(path))) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// InTestBinary is true when the running executable is a `go test` binary. +func InTestBinary() bool { return strings.HasSuffix(os.Args[0], ".test") } + +// GuardTestPath is the fail-closed rule: a test binary may only touch a config under the temp dir. +func GuardTestPath(path string) error { + if InTestBinary() && !UnderTemp(path) { + return fmt.Errorf("refusing: test binary would touch real config %s (set CFEX_CONFIG under the temp dir; use `make test`)", path) + } + return nil +} + +func (c *Config) Save() error { + if err := GuardTestPath(Path()); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(Path()), 0o700); err != nil { + return err + } + b, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(Path(), b, 0o600) +} + +func (c *Config) Route(host string) *Route { + for i := range c.Routes { + if c.Routes[i].Host == host { + return &c.Routes[i] + } + } + return nil +} + +func (c *Config) DropRoute(host string) { + out := c.Routes[:0] + for _, r := range c.Routes { + if r.Host != host { + out = append(out, r) + } + } + c.Routes = out +} diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go new file mode 100644 index 0000000..2ebc592 --- /dev/null +++ b/internal/cfg/cfg_test.go @@ -0,0 +1,43 @@ +package cfg + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + if p := os.Getenv("CFEX_CONFIG"); p == "" || !UnderTemp(p) { + println("refusing to run tests: CFEX_CONFIG must point under the temp dir (use `make test`)") + os.Exit(2) + } + os.Exit(m.Run()) +} + +func TestUnderTemp(t *testing.T) { + if !UnderTemp(filepath.Join(t.TempDir(), "config.yaml")) { + t.Fatal("t.TempDir must count as temp") + } + home, _ := os.UserHomeDir() + for _, bad := range []string{filepath.Join(home, ".config", "cfex", "config.yaml"), "/etc/x.yaml", filepath.Join(os.TempDir(), "..", "x", "config.yaml")} { + if UnderTemp(bad) { + t.Fatalf("%s must not count as temp", bad) + } + } +} + +// Proves the fail-closed guard: pointing the config at the real location makes Save refuse and write nothing. +func TestSaveRefusesRealConfigInTestBinary(t *testing.T) { + home, _ := os.UserHomeDir() + real := filepath.Join(home, ".config", "cfex", "config.yaml") + t.Setenv("CFEX_CONFIG", real) + var before os.FileInfo + before, _ = os.Stat(real) + if err := Default().Save(); err == nil { + t.Fatal("Save must refuse to write the real config from a test binary") + } + after, _ := os.Stat(real) + if (before == nil) != (after == nil) || (before != nil && !before.ModTime().Equal(after.ModTime())) { + t.Fatal("real config was touched") + } +} diff --git a/internal/disc/disc.go b/internal/disc/disc.go new file mode 100644 index 0000000..4436bb1 --- /dev/null +++ b/internal/disc/disc.go @@ -0,0 +1,142 @@ +// Package disc is read-only local discovery: launchd jobs, ~/.cloudflared scripts and token-file +// NAMES. It never prints a token value; the tunnel id inside a token is read in memory only. +package disc + +import ( + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +type Job struct { + Label string // launchd label + Plist string + Loaded bool + PID int + LastExit string + Script string // script path run by the plist, if any + TokenFile string // token file NAME (no value) + TunnelID string // decoded in memory from the token file; "" if unknown + Disabled bool + ForwardPort string // local port of an ssh -L forward ("" for cloudflared jobs) +} + +var fwdRe = regexp.MustCompile(`(?:127\.0\.0\.1:)?(\d+):[^<]+`) +var tokRe = regexp.MustCompile(`([A-Za-z0-9_.-]+-token)\b`) +var scriptRe = regexp.MustCompile(`([^<>\s]+\.sh)`) + +// TunnelIDFromToken decodes a connector token (base64 JSON {"a","t","s"}) and returns only "t". +func TunnelIDFromToken(tok string) string { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(tok)) + if err != nil { + b, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(tok)) + if err != nil { + return "" + } + } + var m struct { + T string `json:"t"` + } + if json.Unmarshal(b, &m) != nil { + return "" + } + return m.T +} + +func launchctlState() map[string][2]string { // label -> pid, status + out, _ := exec.Command("launchctl", "list").Output() + m := map[string][2]string{} + for _, ln := range strings.Split(string(out), "\n")[1:] { + f := strings.Fields(ln) + if len(f) == 3 { + m[f[2]] = [2]string{f[0], f[1]} + } + } + return m +} + +func Jobs() []Job { + home, _ := os.UserHomeDir() + st := launchctlState() + var jobs []Job + plists, _ := filepath.Glob(filepath.Join(home, "Library", "LaunchAgents", "*")) + for _, p := range plists { + base := filepath.Base(p) + if !strings.HasSuffix(base, ".plist") && !strings.HasSuffix(base, ".plist.disabled") { + continue // skips editor/backup copies such as *.plist.bak-1005 + } + if !strings.Contains(base, "tunnel") && !strings.Contains(base, "cloudflared") { + continue + } + b, _ := os.ReadFile(p) + // A plist can embed a token in its arguments; only the script path and token-file NAME are extracted. + txt := string(b) + j := Job{Plist: p, Disabled: strings.HasSuffix(base, ".disabled")} + j.Label = strings.TrimSuffix(strings.TrimSuffix(base, ".disabled"), ".plist") + if m := scriptRe.FindStringSubmatch(txt); m != nil { + j.Script = strings.ReplaceAll(strings.Replace(m[1], "~", home, 1), "$HOME", home) + } + if strings.Contains(txt, "/usr/bin/ssh") { + if m := fwdRe.FindStringSubmatch(txt); m != nil { + j.ForwardPort = m[1] + } + } + if s, ok := st[j.Label]; ok { + j.Loaded = true + j.PID, _ = strconv.Atoi(s[0]) + j.LastExit = s[1] + } + if j.Script != "" { + if sb, err := os.ReadFile(j.Script); err == nil { + if m := tokRe.FindString(string(sb)); m != "" { + j.TokenFile = m + if tb, err := os.ReadFile(filepath.Join(home, ".cloudflared", m)); err == nil { + j.TunnelID = TunnelIDFromToken(string(tb)) + } + } + } + } + jobs = append(jobs, j) + } + return jobs +} + +// Scripts lists ~/.cloudflared/*-tunnel.sh and token-file NAMES (never contents). +func Scripts() (scripts, tokenFiles []string) { + home, _ := os.UserHomeDir() + s, _ := filepath.Glob(filepath.Join(home, ".cloudflared", "*-tunnel.sh")) + t, _ := filepath.Glob(filepath.Join(home, ".cloudflared", "*-token")) + for _, x := range s { + scripts = append(scripts, filepath.Base(x)) + } + for _, x := range t { + tokenFiles = append(tokenFiles, filepath.Base(x)) + } + return +} + +// RunningCloudflaredCount counts processes without ever printing their arguments. +func RunningCloudflaredCount() int { + out, _ := exec.Command("pgrep", "-f", "cloudflared").Output() + n := 0 + for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if l != "" { + n++ + } + } + return n +} + +// ShortName strips the launchd prefix/suffix so a label can be compared with a tunnel name. +func ShortName(label string) string { + l := label + if i := strings.LastIndex(l, "."); i >= 0 && i < len(l)-1 { + l = l[i+1:] + } + return strings.TrimSuffix(l, "-tunnel") +} diff --git a/internal/ingress/ingress.go b/internal/ingress/ingress.go new file mode 100644 index 0000000..9dab89e --- /dev/null +++ b/internal/ingress/ingress.go @@ -0,0 +1,102 @@ +// Package ingress holds the PURE merge logic for a tunnel's ingress rule list. +// Every function returns a new slice and never mutates its input, and each only +// touches the rule(s) for the one hostname it is given: adding or removing +// route B can never alter route A. +package ingress + +import ( + "errors" + "fmt" +) + +// Rule is one ingress entry exactly as the Cloudflare API returns it (kept generic so unknown +// fields such as originRequest survive a round trip untouched). +type Rule = map[string]any + +const CatchAll = "http_status:404" + +var ErrExists = errors.New("hostname already has an ingress rule on this tunnel") +var ErrNotFound = errors.New("no ingress rule for that hostname") + +func host(r Rule) string { h, _ := r["hostname"].(string); return h } + +// IsCatchAll reports whether r is the mandatory final hostname-less rule. +func IsCatchAll(r Rule) bool { return host(r) == "" } + +func clone(rules []Rule) []Rule { + out := make([]Rule, 0, len(rules)+2) + for _, r := range rules { + c := Rule{} + for k, v := range r { + c[k] = v + } + out = append(out, c) + } + return out +} + +// Normalize returns a copy that ends with exactly one catch-all rule. +func Normalize(rules []Rule) []Rule { + out := make([]Rule, 0, len(rules)+1) + var catch Rule + for _, r := range clone(rules) { + if IsCatchAll(r) { + catch = r + continue + } + out = append(out, r) + } + if catch == nil { + catch = Rule{"service": CatchAll} + } + return append(out, catch) +} + +// Has reports whether any rule carries hostname h. +func Has(rules []Rule, h string) bool { + for _, r := range rules { + if host(r) == h { + return true + } + } + return false +} + +// Add appends one rule for hostname -> service just before the catch-all. +func Add(rules []Rule, hostname, service string) ([]Rule, error) { + if hostname == "" || service == "" { + return nil, fmt.Errorf("hostname and service are required") + } + if Has(rules, hostname) { + return nil, ErrExists + } + n := Normalize(rules) + last := n[len(n)-1] + n = append(n[:len(n)-1], Rule{"hostname": hostname, "service": service}, last) + return n, nil +} + +// Remove drops every rule for hostname and nothing else. +func Remove(rules []Rule, hostname string) ([]Rule, error) { + if !Has(rules, hostname) { + return nil, ErrNotFound + } + out := make([]Rule, 0, len(rules)) + for _, r := range clone(rules) { + if host(r) != hostname { + out = append(out, r) + } + } + return Normalize(out), nil +} + +// Service returns the service of the first rule for hostname. +func Service(rules []Rule, hostname string) (string, bool) { + for _, r := range rules { + if host(r) == hostname { + s, _ := r["service"].(string) + return s, true + } + } + return "", false +} diff --git a/internal/ingress/ingress_test.go b/internal/ingress/ingress_test.go new file mode 100644 index 0000000..732d568 --- /dev/null +++ b/internal/ingress/ingress_test.go @@ -0,0 +1,61 @@ +package ingress + +import ( + "reflect" + "testing" +) + +func base() []Rule { + return []Rule{ + {"hostname": "a.example.com", "service": "http://127.0.0.1:1", "originRequest": map[string]any{"noTLSVerify": true}}, + {"service": CatchAll}, + } +} + +func TestAddNeverAltersExistingRoute(t *testing.T) { + in := base() + snapshot := clone(in) + out, err := Add(in, "b.example.com", "http://127.0.0.1:2") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(in, snapshot) { + t.Fatal("Add mutated its input") + } + if !reflect.DeepEqual(out[0], in[0]) { + t.Fatalf("route A changed: %v", out[0]) + } + if got := host(out[1]); got != "b.example.com" { + t.Fatalf("B not before catch-all: %v", out) + } + if !IsCatchAll(out[len(out)-1]) { + t.Fatal("catch-all must stay last") + } +} + +func TestRemoveOnlyTouchesThatHost(t *testing.T) { + r, _ := Add(base(), "b.example.com", "http://127.0.0.1:2") + out, err := Remove(r, "b.example.com") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out, base()) { + t.Fatalf("after add+remove expected original, got %v", out) + } +} + +func TestDuplicateAndMissing(t *testing.T) { + if _, err := Add(base(), "a.example.com", "http://x"); err != ErrExists { + t.Fatalf("want ErrExists got %v", err) + } + if _, err := Remove(base(), "zzz.example.com"); err != ErrNotFound { + t.Fatalf("want ErrNotFound got %v", err) + } +} + +func TestNormalizeAddsCatchAll(t *testing.T) { + out, err := Add(nil, "a.example.com", "http://x") + if err != nil || !IsCatchAll(out[len(out)-1]) || len(out) != 2 { + t.Fatalf("%v %v", out, err) + } +} From f8064ac99c31df3a4923d329fb77ce18beede365 Mon Sep 17 00:00:00 2001 From: Muthukumaran Navaneethakrishnan Date: Mon, 5 Oct 2026 20:27:21 +0530 Subject: [PATCH 2/2] v2 on cloudflared: instant mode with cleanup, durable routes, agent skill, uninstall, releases main becomes v2 only; the bash version lives on as the v1.0.0 release. - cloudflared does the tunnel work (create, route dns, list, delete, run); the API token is optional and only adds DNS ownership checks, DNS deletion, remote ingress and zone validation - instant mode (cfex host:port) removes its tunnel and DNS record on exit; --keep makes it durable - delete asks y/N on a terminal, --yes for scripts, dry run without a terminal - cfex skill install, cfex uninstall, troubleshoot - GoReleaser config, release workflow, install.sh that verifies checksums --- .github/workflows/release.yml | 21 ++ .goreleaser.yaml | 25 ++ README.md | 192 +++------- bin/cfex | 656 ---------------------------------- cmd/cfex/add.go | 156 ++++---- cmd/cfex/common.go | 58 ++- cmd/cfex/delete.go | 177 +++++++-- cmd/cfex/delete_test.go | 75 ++-- cmd/cfex/diag.go | 89 +++-- cmd/cfex/instant.go | 324 +++++++++++++++++ cmd/cfex/legacy.go | 260 -------------- cmd/cfex/list.go | 234 +++++++----- cmd/cfex/main.go | 67 +++- cmd/cfex/main_test.go | 143 ++++++-- cmd/cfex/misc_test.go | 173 +++++++++ cmd/cfex/service.go | 74 +--- cmd/cfex/skill.go | 117 ++++++ cmd/cfex/stopstart.go | 31 +- cmd/cfex/tunnelfile.go | 69 ++++ config.example.yaml | 11 +- embed.go | 9 + install.sh | 70 ++-- internal/cf/cf.go | 5 + internal/cfd/cfd.go | 146 ++++++++ internal/cfd/cfd_test.go | 25 ++ internal/cfg/cfg.go | 55 +-- package.json | 10 - skills/cfex/SKILL.md | 40 +++ uninstall.sh | 49 +-- 29 files changed, 1819 insertions(+), 1542 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml delete mode 100755 bin/cfex create mode 100644 cmd/cfex/instant.go delete mode 100644 cmd/cfex/legacy.go create mode 100644 cmd/cfex/misc_test.go create mode 100644 cmd/cfex/skill.go create mode 100644 cmd/cfex/tunnelfile.go create mode 100644 embed.go mode change 100644 => 100755 install.sh create mode 100644 internal/cfd/cfd.go create mode 100644 internal/cfd/cfd_test.go delete mode 100644 package.json create mode 100644 skills/cfex/SKILL.md mode change 100644 => 100755 uninstall.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bb9483b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: release +on: + push: + tags: ['v2*'] +permissions: + contents: write +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: actions/setup-go@v5 + with: { go-version: '1.22' } + - run: make test + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..289eec3 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,25 @@ +version: 2 +project_name: cfex +before: + hooks: + - go mod tidy +builds: + - id: cfex + main: ./cmd/cfex + binary: cfex + env: [CGO_ENABLED=0] + goos: [darwin, linux] + goarch: [amd64, arm64] + ldflags: ["-s -w -X main.version={{.Version}}"] +archives: + - id: cfex + formats: [tar.gz] + name_template: "cfex_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: [LICENSE, README.md] +checksum: + name_template: checksums.txt +changelog: + disable: true +release: + extra_files: + - glob: ./install.sh diff --git a/README.md b/README.md index ba25d1e..a9cac95 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,97 @@ # cfex - Cloudflare Exposer CLI [![Discord](https://img.shields.io/badge/AgentNexus-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/V9C2kvHC8D) -> **v2 (Go)** adds a durable mode and a machine-wide view on top of everything v1 does. All v1 commands -> (`cfex host:3000`, `cfex host 3000`, `--https`, `--verify-cert`, `cfex list`, `cfex delete host`) keep working. -> The v1 bash script is still in `bin/cfex` until the Go binary is released. +Put a local port on your own domain, over HTTPS, with one command. cfex drives [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) for the tunnels and DNS routes, and manages every tunnel on your machine. -```bash -cfex dev.example.com:3000 # v1: foreground tunnel, Ctrl+C stops it -cfex add 3000 dev.example.com # v2: durable route on cfex's own tunnel, survives reboots (launchd / systemd) -cfex list # EVERYTHING on this machine, with live HTTP status -cfex stop dev.example.com # stop serving, keep config + DNS (cfex start to resume) -cfex delete dev.example.com # dry run; --yes to apply -cfex status | doctor | import -``` - -### What v2 adds -- **`add`**: one named tunnel owned by cfex; each route adds ONE ingress rule (a pure, unit-tested merge that never touches other - rules) and a proxied CNAME stamped `managed-by: cfex`. The connector runs under launchd (macOS) or a systemd user unit (Linux). -- **`list`**: managed routes plus everything else cloudflared-ish on the machine: account tunnels with their ingress rules, launchd - jobs, `~/.cloudflared` scripts, live HTTP status. Classes: `managed`, `PROTECTED`, `client`, `adopted`, `legacy` (v1), `unmanaged`. -- **Safety**: only routes cfex created can be stopped/deleted. `delete` is a dry run unless `--yes` and refuses any DNS record without - the `managed-by: cfex` comment. List tunnels you never want touched under `protected:` / `client:` in the config. -- **Secrets**: the API token is read from `CLOUDFLARE_API_TOKEN` only (or `token_cmd` runs cfex under your secret manager); it is never - printed or written to disk. The connector token is a 0600 file, or lives in your secret manager (`connector_run_cmd`). -- `import` adopts an existing tunnel into the config without changing what it serves. `doctor` checks cloudflared, the token, the - service, DNS <-> ingress consistency and lists plaintext token files by name. - -Config: `~/.config/cfex/config.yaml` (see `config.example.yaml`). Token permissions: Zone:Read, DNS:Edit, Account: Cloudflare Tunnel:Edit. -Build: `make build` · test: `make test` (the suite refuses to run unless `CFEX_CONFIG` points under the temp dir). - -Behaviour differences from v1: replacing an orphaned DNS record now only happens when it is a tunnel CNAME (v1 deleted any record -of that name); `cfex list` without an API token falls back to the v1 view. - ---- - -Expose your local services to the internet using your domain names via Cloudflare's tunneling technology. With cfex, you can create instant HTTPS endpoints for any local service without port forwarding or static IPs. - -## What is cfex? +> The original bash version is preserved as the [v1.0.0 release](https://github.com/muthuishere/cfex-cli/releases/tag/v1.0.0) (legacy). v2 is the maintained version. -cfex (Cloudflare Exposer CLI) simplifies creating secure tunnels between your local development environment and the internet. It leverages Cloudflare’s tunnel technology to: -- Expose local services via HTTPS endpoints. -- Use custom domain names for development and testing. -- Access your services behind firewalls or NAT. -- Test webhooks and APIs locally without additional setup. +## Instant: `cfex :` ---- +```bash +cfex dev.yourdomain.com:3000 # React/Vue/Angular dev server -> https://dev.yourdomain.com +cfex api.yourdomain.com:8080 # local API server +cfex webhook.yourdomain.com:4000 # webhook testing +cfex api.yourdomain.com 8080 # same thing, two arguments +cfex secure.yourdomain.com:8443 --https [--verify-cert] # HTTPS origin (auto-detected when omitted) +``` -## Why cfex? +It runs in the foreground. Press **Ctrl+C**: the tunnel and the DNS record it created are removed. Add `--keep` to make it permanent (same as `cfex add`). -When developing web applications, cfex is useful for: -- **Sharing local development servers**: Share your work with clients or team members. -- **Webhook testing**: Test external integrations directly from your local environment. -- **Accessing local services on mobile**: Use your app on other devices. -- **Demoing features**: Quickly share links for feedback without deploying. +## Durable: `cfex add` -With cfex, you can: ```bash -# Share your React app -cfex dev.yourdomain.com:3000 # https://dev.yourdomain.com - -# Expose your API -cfex api.yourdomain.com:8080 # https://api.yourdomain.com - -# Test webhooks -cfex webhook.yourdomain.com:4000 # https://webhook.yourdomain.com +cfex add 3000 dev.yourdomain.com # survives reboots (launchd on macOS, systemd user unit on Linux) +cfex add 8080 api.yourdomain.com # many routes share one cfex-owned tunnel +cfex list # EVERYTHING on this machine, with live HTTP status +cfex stop dev.yourdomain.com # stop serving, keep config + DNS (cfex start to resume) +cfex delete dev.yourdomain.com # asks y/N on a terminal; --yes for scripts; no terminal and no --yes = dry run +cfex status | doctor | troubleshoot +cfex import # adopt an existing tunnel into the config; serving is not changed ``` ---- +`add` appends ONE ingress rule to cfex's own tunnel (a pure, unit-tested merge that never touches other routes) and creates the CNAME with `cloudflared tunnel route dns`, which never overwrites an existing record. Changing a route restarts the connector, so routes on that tunnel reconnect for a second or two. -## Quick Start +`list` shows managed routes plus everything else cloudflared-ish here: account tunnels with their ingress rules, launchd jobs, `~/.cloudflared` scripts, live HTTP status. Classes: `managed`, `instant`, `PROTECTED`, `client`, `adopted`, `unmanaged`. Put tunnels you never want touched under `protected:` / `client:` in the config; cfex only stops/deletes what it created. -### Prerequisites -1. **Cloudflare Account with Registered Domain**: [Sign up for free](https://dash.cloudflare.com/). -2. **cloudflared Installed**: [Install cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/). -3. **jq Installed**: - - macOS: `brew install jq` - - Ubuntu/Debian: `sudo apt install jq` -4. **Cloudflare API Token**: [Create API Token](https://dash.cloudflare.com/profile/api-tokens) with: - - Permissions: `Zone-DNS Edit` and `Zone-Read` - - Zone: Include the domain you want to use. +## Install -### Installation ```bash -# Install cfex -curl -sSL https://raw.githubusercontent.com/muthuishere/cfex-cli/main/install.sh | bash +curl -sSL https://github.com/muthuishere/cfex-cli/releases/latest/download/install.sh | bash ``` -### Authenticate Cloudflared +Downloads the release binary for your OS/CPU (macOS and Linux, amd64 and arm64), verifies its checksum, installs it to `/usr/local/bin` (or `~/.local/bin`) and installs the agent skill. Binaries and checksums are on the [releases page](https://github.com/muthuishere/cfex-cli/releases). From source: `go install github.com/muthuishere/cfex-cli/cmd/cfex@latest`. + +Prerequisites: a Cloudflare account with a domain, and [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed. + +### Authenticate cloudflared (once) ```bash cloudflared tunnel login ``` -This command: -- Opens Cloudflare's login page. -- Creates a certificate at `~/.cloudflared/cert.pem`. +Opens Cloudflare's login page: log in, pick the PRIMARY domain you want to tunnel, authorize. This writes `~/.cloudflared/cert.pem`; afterwards you can tunnel any subdomain of that domain. If you forget, cfex starts this guided login for you. -### Set API Token -```bash -export CLOUDFLARE_API_KEY='your-api-token' -``` -For persistence: +### API token (optional) +cfex works with cloudflared alone. An API token adds: DNS ownership checks (`managed-by: cfex` stamped on the records it creates), deleting DNS records on `delete`/Ctrl+C cleanup, remote ingress in `list`, and zone validation. `cfex doctor` tells you which features are on. + +Create one at : *Create Token* → *Edit zone DNS* template → *Zone Resources: Include → Specific zone* → your domain. Then: ```bash -echo 'export CLOUDFLARE_API_KEY="your-api-token"' >> ~/.bashrc # or ~/.zshrc -source ~/.bashrc # or ~/.zshrc +export CLOUDFLARE_API_TOKEN='your-api-token' # CLOUDFLARE_API_KEY also works; add it to ~/.zshrc or ~/.bashrc to persist ``` +Or let cfex fetch it from your secret manager: `token_cmd: "op run --"` in the config (cfex re-runs itself under that command; the token only exists in its environment). ---- +## Agent skill -## Usage - -### Expose Services -```bash -# React/Vue/Angular dev server -cfex dev.yourdomain.com:3000 +`cfex skill install` copies [`skills/cfex/SKILL.md`](skills/cfex/SKILL.md) to `~/.claude/skills/cfex` and `~/.agents/skills/cfex`, so coding agents know the commands and the safety rules (`cfex skill uninstall` removes it). -# Local API server -cfex api.yourdomain.com:8080 +## Uninstall -# Webhook testing -cfex webhook.yourdomain.com:4000 -``` - -### Manage Tunnels ```bash -# List tunnels -cfex list - -# Delete tunnel(s) -cfex delete domain.com - -# Show help -cfex --help +cfex uninstall # or ./uninstall.sh ``` +Removes the binary and the skill, then asks (default: no) whether to stop the cfex service, remove cfex's config, and remove cloudflared's own files in `~/.cloudflared`. Flags for scripts: `--stop-service --purge-config --purge-cloudflared`. Tunnels and DNS records in your Cloudflare account are never deleted; run `cfex delete` first if you want them gone. ---- - -## Troubleshooting - -1. **Installation Issues**: - - Verify `cloudflared` and `jq` are installed. - - Check permissions for installation directories. +## Configuration -2. **Authentication Issues**: - - Ensure the API token is set and valid. - - Re-run `cloudflared tunnel login` if needed. +`~/.config/cfex/config.yaml` (every key optional; see [`config.example.yaml`](config.example.yaml)): `default_zone`, `tunnel_name`, `service_label`, `token_env`, `token_cmd`, `protected`, `client`. State and logs: `~/.local/share/cfex/`. -3. **DNS Issues**: - - Allow time for DNS propagation. - - Verify domain ownership and permissions. - -4. **Connection Issues**: - - Ensure the service is running on the specified port. - - Check for port conflicts or firewall settings. +## Troubleshooting ---- +1. **Installation**: verify `cloudflared` is installed (`cfex doctor`); check permissions on the install directory. +2. **Authentication**: re-run `cloudflared tunnel login`; if you use an API token, make sure it is exported and valid. +3. **DNS**: allow time for propagation; make sure the domain is in your Cloudflare account and the hostname has no existing record (cfex never overwrites one). +4. **Connection**: make sure the service is running on the port you gave (cfex warns when nothing listens); check port conflicts and firewalls. -## Platform Support -- **Linux** -- **macOS** -- **Windows** (via WSL): - - Install WSL: `wsl --install` in PowerShell (Admin). - - Restart your computer. - - Follow Linux setup instructions. +## Platform support +- **Linux** and **macOS** (amd64, arm64). The durable service uses systemd (user) and launchd. +- **Windows** via WSL: `wsl --install` in an elevated PowerShell, restart, then follow the Linux steps. ---- +## Development +`make build` · `make test` (the suite refuses to run unless `CFEX_CONFIG` points under the temp dir) · releases are built by GoReleaser from a `v2*` tag. ## Alternatives -- **ngrok**: Subscription required for custom domains. -- **LocalTunnel**: Free, but lacks features like DNS management. -- **cloudflared**: The native client that cfex simplifies. - -cfex provides a streamlined solution for exposing your local services using Cloudflare, making it ideal for developers working with custom domains and secure tunnels. +- **ngrok**: subscription required for custom domains. +- **LocalTunnel**: free, but lacks DNS management. +- **cloudflared**: the native client that cfex simplifies. ## Community -Questions, ideas, or built something with this? Join **[AgentNexus](https://discord.gg/V9C2kvHC8D)** — a Discord -for people building with AI agents and open tools. This project lives in **#cfex-cli**. +Questions, ideas, or built something with this? Join **[AgentNexus](https://discord.gg/V9C2kvHC8D)**, a Discord for people building with AI agents and open tools. This project lives in **#cfex-cli**. diff --git a/bin/cfex b/bin/cfex deleted file mode 100755 index 3eab4dc..0000000 --- a/bin/cfex +++ /dev/null @@ -1,656 +0,0 @@ -#!/bin/bash - -readonly VERSION="1.0.0" -readonly MAX_WAIT_TIME=60 # Currently not defined globally -readonly CLOUDFLARED_DIR="$HOME/.cloudflared" - -validate_basic_requirements() { - local errors=() - - # Check cloudflared installation - if ! command -v cloudflared &> /dev/null; then - errors+=("Error: cloudflared is not installed" - "Please install cloudflared first") - fi - - if [ ! -f "$HOME/.cloudflared/cert.pem" ]; then - echo "Error: cloudflared not authenticated" - echo "" - echo "Authentication Process:" - echo "1. You will be redirected to Cloudflare's login page" - echo "2. Log in to your Cloudflare account" - echo "3. Select the PRIMARY domain you want to use for tunneling" - echo " (This should be the root domain you own, e.g., yourdomain.com)" - echo "4. Authorize Tunnel for domain" - echo "" - echo "Note: Once authenticated, you can create tunnels for any subdomain" - echo "under your authorized primary domain." - echo "" - echo "Starting authentication process in 3 seconds..." - echo "" - sleep 3 - cloudflared tunnel login - - if [ $? -eq 0 ]; then - echo "" - echo "Authentication successful!" - echo "You can now create tunnels for your domain and its subdomains." - echo "" - else - echo "" - echo "Authentication failed. Please try again." - echo "" - fi - - exit 1 - fi - - # Check API key for operations that need it - if [ -z "$CLOUDFLARE_API_KEY" ]; then - errors+=("Error: CLOUDFLARE_API_KEY environment variable is not set" - "Create your API token at: https://dash.cloudflare.com/profile/api-tokens" - "1. Click 'Create Token'" - "2. Use the 'Edit zone DNS' template" - "3. Under 'Zone Resources', select 'Include Specific Zone'" - "4. Choose the domain you authenticated with cloudflared" - "" - "Then set it using: export CLOUDFLARE_API_KEY='your-api-token'") - fi - - # Check if jq is installed (needed for JSON processing) - if ! command -v jq &> /dev/null; then - errors+=("Error: jq is not installed" - "Please install jq first") - fi - - # If there are any errors, print them and return failure - if [ ${#errors[@]} -ne 0 ]; then - echo "Validation Failed!" - printf '%s\n' "${errors[@]}" - return 1 - fi - - return 0 -} - - -log_error() { - echo "Error: $1" >&2 -} - -list_tunnels() { - echo "Active Tunnels:" - echo "----------------------------------------" - - # Get the tunnel list JSON - local tunnel_json=$(cloudflared tunnel list --output json) - - # Check if the output is empty or null - if [ -z "$tunnel_json" ] || [ "$tunnel_json" = "null" ]; then - echo "No active tunnels found." - return 0 - fi - - # Process the JSON only if it's valid - echo "$tunnel_json" | jq -r '.[] | select(.id != null) | - . as $tunnel | - .name | - if startswith("tunnel_") then - (.[7:] | gsub("_"; ".")) - else - . - end + " " + $tunnel.id' | - while read -r line; do - if [ ! -z "$line" ]; then - domain=$(echo "$line" | cut -d' ' -f1) - id=$(echo "$line" | cut -d' ' -f2) - printf "%-40s %s\n" "$domain" "$id" - fi - done - - # If no tunnels were printed, show a message - if [ -z "$(echo "$tunnel_json" | jq -r '.[] | select(.id != null)')" ]; then - echo "No active tunnels found." - fi -} - - -delete_tunnel() { - local domains=("${@:2}") # Start from the second argument - local total_domains=${#domains[@]} - - if [ $total_domains -eq 0 ]; then - echo "Error: No domains provided for deletion" - echo "Usage: cfex delete [domain2] [domain3] ..." - exit 1 - fi - - # Show all domains that will be deleted - echo "The following tunnels will be deleted:" - for domain in "${domains[@]}"; do - local tunnel_name="tunnel_$(echo $domain | tr '.' '_')" - if cloudflared tunnel list | grep -q "$tunnel_name"; then - echo "- $domain" - else - echo "- $domain (not found)" - fi - done - - # Single confirmation for all domains - read -p "Are you sure you want to delete these $total_domains tunnels? (y/N) " confirm - if [[ "$confirm" =~ ^[Yy]$ ]]; then - for domain in "${domains[@]}"; do - local tunnel_name="tunnel_$(echo $domain | tr '.' '_')" - - # Check if tunnel exists - if ! cloudflared tunnel list | grep -q "$tunnel_name"; then - echo "Skipping $domain: No tunnel found" - continue - fi - - echo "Processing deletion for $domain..." - - # Get tunnel ID - local tunnel_id=$(cloudflared tunnel list | grep "$tunnel_name" | awk '{print $1}') - - # Set up zone information for DNS deletion - FULL_DOMAIN=$domain - if [[ "$domain" =~ ^[^.]+\.[^.]+$ ]]; then - CF_ZONE_NAME="$domain" - else - CF_ZONE_NAME=$(echo "$domain" | cut -d. -f2-) - fi - - # Only proceed with DNS cleanup if we have API key and tunnel exists - if [ ! -z "$CLOUDFLARE_API_KEY" ]; then - CF_ZONE_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE_NAME" \ - -H "Authorization: Bearer $CLOUDFLARE_API_KEY" \ - -H "Content-Type: application/json" | jq -r '.result[0].id') - - if [ $? -ne 0 ]; then - echo "Error: Failed to make API request for $domain" - continue - fi - fi - - # Cleanup existing connections - echo "Cleaning up existing connections for $domain..." - cloudflared tunnel cleanup "$tunnel_name" - - # Delete the tunnel - echo "Deleting tunnel for $domain..." - if cloudflared tunnel delete -f "$tunnel_name"; then - # Only delete DNS records if tunnel deletion was successful - if [ ! -z "$CLOUDFLARE_API_KEY" ]; then - echo "Tunnel deleted successfully, proceeding with DNS cleanup..." - delete_dns_record - wait_for_dns_deletion - fi - echo "Tunnel for $domain has been deleted." - else - echo "Failed to delete tunnel for $domain, skipping DNS cleanup." - fi - echo "----------------------------------------" - done - echo "Deletion process completed." - else - echo "Operation cancelled." - exit 0 - fi -} - -# Help and version information -show_help() { - echo "Usage:" - echo " cfex : Auto-detect HTTP/HTTPS and create tunnel" - echo " cfex : --https Force HTTPS mode for local service" - echo " cfex : --https --verify-cert Force HTTPS with cert verification" - echo " cfex Auto-detect HTTP/HTTPS (original syntax)" - echo " cfex --https Force HTTPS mode" - echo " cfex list List all active tunnels" - echo " cfex delete Delete tunnel for specified domain" - echo "" - echo "Options:" - echo " --https Force HTTPS mode for local service" - echo " --verify-cert Verify SSL certificates (default: ignore cert errors)" - echo " -h, --help Show this help message" - echo " -v, --version Show version information" - echo "" - echo "Examples:" - echo " cfex example.com:8080 Auto-detect if port 8080 is HTTP/HTTPS" - echo " cfex example.com:8443 --https Force HTTPS tunnel for port 8443" - echo " cfex sub.example.com:3000 --https --verify-cert HTTPS tunnel with cert verification" - echo " cfex api.example.com 8080 Auto-detect protocol for port 8080" - echo " cfex list Show all active tunnels" - echo " cfex delete example.com Delete tunnel for example.com" - echo "" - echo "Note: The tool automatically detects if your local service is HTTP or HTTPS." - echo "Certificate errors are ignored by default for easier local development." - echo "Use --verify-cert if you need strict certificate validation." -} - -dns_record_exists() { - local types=("A" "AAAA" "CNAME") - for type in "${types[@]}"; do - local record_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?type=$type&name=$FULL_DOMAIN" \ - -H "Authorization: Bearer $CLOUDFLARE_API_KEY" \ - -H "Content-Type: application/json" | jq -r '.result[0].id') - - if [ "$record_id" != "null" ] && [ ! -z "$record_id" ]; then - return 0 # Record exists - fi - done - return 1 # No record found -} -delete_dns_record() { - local records=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?name=$FULL_DOMAIN" \ - -H "Authorization: Bearer $CLOUDFLARE_API_KEY" \ - -H "Content-Type: application/json" | jq -r '.result[] | .id') - - if [ ! -z "$records" ]; then - echo "Deleting all DNS records for $FULL_DOMAIN..." - for record_id in $records; do - echo "Deleting record ID: $record_id" - curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$record_id" \ - -H "Authorization: Bearer $CLOUDFLARE_API_KEY" \ - -H "Content-Type: application/json" - sleep 1 # Add a small delay between deletions - done - fi -} - -# Function to wait for DNS record deletion -wait_for_dns_deletion() { - local start_time=$(date +%s) - local current_time - - echo "Waiting for DNS record deletion to complete..." - while dns_record_exists; do - current_time=$(date +%s) - if [ $((current_time - start_time)) -ge $MAX_WAIT_TIME ]; then - echo "Timeout waiting for DNS record deletion" - exit 1 - fi - echo "Still waiting for DNS record deletion... ($(($MAX_WAIT_TIME - $(($current_time - start_time)))) seconds remaining)" - sleep 2 - done - echo "DNS record deletion confirmed" -} - -# Function to check if tunnel exists -tunnel_exists() { - cloudflared tunnel list | grep -q "$TUNNEL_NAME" - return $? -} - -# Function to wait for tunnel deletion -wait_for_tunnel_deletion() { - local start_time=$(date +%s) - local current_time - - echo "Waiting for tunnel deletion to complete..." - while tunnel_exists; do - current_time=$(date +%s) - if [ $((current_time - start_time)) -ge $MAX_WAIT_TIME ]; then - echo "Timeout waiting for tunnel deletion" - exit 1 - fi - echo "Still waiting for tunnel deletion... ($(($MAX_WAIT_TIME - $(($current_time - start_time)))) seconds remaining)" - sleep 2 - done - echo "Tunnel deletion confirmed" -} - - - -validate_requirements() { - local FULL_DOMAIN=$1 - local LOCAL_PORT=$2 - - local errors=() - - # Validate local port - if ! [[ $LOCAL_PORT =~ ^[0-9]+$ ]]; then - errors+=("Error: local_port must be a number") - elif [ $LOCAL_PORT -lt 1 ] || [ $LOCAL_PORT -gt 65535 ]; then - errors+=("Error: local_port must be between 1 and 65535") - fi - - # Check API key - if [ -z "$CLOUDFLARE_API_KEY" ]; then - errors+=("Error: CLOUDFLARE_API_KEY environment variable is not set" - "Please set it using: export CLOUDFLARE_API_KEY='your-api-token'") - fi - - # Check cloudflared installation - if ! command -v cloudflared &> /dev/null; then - errors+=("Error: cloudflared is not installed") - fi - - # Check cloudflared authentication - if [ ! -f "$HOME/.cloudflared/cert.pem" ]; then - errors+=("Error: cloudflared not authenticated" - "Please authenticate first by running: cloudflared tunnel login") - fi - - # Validate Cloudflare zone - if [[ "$FULL_DOMAIN" =~ ^[^.]+\.[^.]+$ ]]; then - CF_ZONE_NAME="$FULL_DOMAIN" - else - CF_ZONE_NAME=$(echo "$FULL_DOMAIN" | cut -d. -f2-) - fi - - CF_ZONE_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE_NAME" \ - -H "Authorization: Bearer $CLOUDFLARE_API_KEY" \ - -H "Content-Type: application/json" | jq -r '.result[0].id') - - if [ -z "$CF_ZONE_ID" ]; then - errors+=("Error: Could not fetch zone data for domain $CF_ZONE_NAME" - "Possible issues:" - "1. Invalid API token" - "2. Domain '$CF_ZONE_NAME' not found in your Cloudflare account" - "3. API token doesn't have permission to access this zone") - fi - - # If there are any errors, print them and exit - if [ ${#errors[@]} -ne 0 ]; then - printf '%s\n' "${errors[@]}" - return 1 - fi - - return 0 -} - - - -create_tunnel() { - local FULL_DOMAIN=$1 - local LOCAL_PORT=$2 - local PROTOCOL=${3:-"http"} # Default to http - local IGNORE_CERT=${4:-"true"} # Default to true (ignore cert errors) - - # Validate requirements first - if ! validate_requirements "$FULL_DOMAIN" "$LOCAL_PORT"; then - exit 1 - fi - - local TUNNEL_NAME="tunnel_$(echo $FULL_DOMAIN | tr '.' '_')" - local CONFIG_DOMAIN_NAME=$(echo "$FULL_DOMAIN" | tr '.' '_') - local CONFIG_FILE="$CLOUDFLARED_DIR/${CONFIG_DOMAIN_NAME}_config.yml" - - # Check if tunnel already exists — if so, just update config and run - if tunnel_exists; then - echo "Found existing tunnel named $TUNNEL_NAME" - - # Get existing tunnel ID - local TUNNEL_ID=$(cloudflared tunnel list --output json | jq -r --arg name "$TUNNEL_NAME" '.[] | select(.name == $name) | .id') - if [ -z "$TUNNEL_ID" ]; then - echo "Error: Could not retrieve tunnel ID for $TUNNEL_NAME" - exit 1 - fi - echo "Tunnel ID: $TUNNEL_ID" - - # Cleanup any stale connections before re-running - echo "Cleaning up stale connections..." - cloudflared tunnel cleanup "$TUNNEL_NAME" 2>/dev/null - - # Auto-detect protocol - local detected_protocol="http" - echo "Detecting local service protocol on port $LOCAL_PORT..." - if timeout 3 openssl s_client -connect localhost:$LOCAL_PORT -servername localhost /dev/null 2>&1; then - detected_protocol="https" - echo "✓ Detected HTTPS service on port $LOCAL_PORT" - elif timeout 3 curl -s -k https://localhost:$LOCAL_PORT >/dev/null 2>&1; then - detected_protocol="https" - echo "✓ Detected HTTPS service on port $LOCAL_PORT (via curl)" - else - echo "✓ Detected HTTP service on port $LOCAL_PORT" - fi - if [ "$PROTOCOL" = "http" ] && [ "$detected_protocol" = "https" ]; then - PROTOCOL="https" - echo "→ Automatically switching to HTTPS mode for HTTPS service" - fi - - # Update config file with new port/protocol - echo "Updating config file with port $LOCAL_PORT..." - if [ "$PROTOCOL" = "https" ]; then - if [ "$IGNORE_CERT" = "true" ]; then - cat > "$CONFIG_FILE" << EOF -url: https://localhost:$LOCAL_PORT -tunnel: $TUNNEL_ID -credentials-file: $CLOUDFLARED_DIR/$TUNNEL_ID.json -originRequest: - noTLSVerify: true - disableChunkedEncoding: true -EOF - else - cat > "$CONFIG_FILE" << EOF -url: https://localhost:$LOCAL_PORT -tunnel: $TUNNEL_ID -credentials-file: $CLOUDFLARED_DIR/$TUNNEL_ID.json -originRequest: - disableChunkedEncoding: true -EOF - fi - else - cat > "$CONFIG_FILE" << EOF -url: http://localhost:$LOCAL_PORT -tunnel: $TUNNEL_ID -credentials-file: $CLOUDFLARED_DIR/$TUNNEL_ID.json -EOF - fi - - echo "Config file updated at $CONFIG_FILE" - echo "Configuration contents:" - cat "$CONFIG_FILE" - - # Run the tunnel directly — DNS route already exists - echo "Starting the tunnel..." - echo "Your service will be accessible at https://$FULL_DOMAIN" - if [ "$PROTOCOL" = "https" ]; then - echo "→ Tunneling HTTPS traffic from localhost:$LOCAL_PORT" - if [ "$IGNORE_CERT" = "true" ]; then - echo "→ SSL certificate verification is disabled for local HTTPS service" - else - echo "→ SSL certificate verification is enabled" - fi - else - echo "→ Tunneling HTTP traffic from localhost:$LOCAL_PORT" - fi - echo "Press Ctrl+C to stop the tunnel" - - echo "Executing command: cloudflared tunnel --config $CONFIG_FILE run $TUNNEL_NAME" - cloudflared tunnel --config $CONFIG_FILE run $TUNNEL_NAME - return - fi - - # Handle orphaned DNS records (tunnel gone but DNS still exists) - if dns_record_exists; then - echo "Found existing DNS record for $FULL_DOMAIN (no matching tunnel)" - delete_dns_record - wait_for_dns_deletion - echo "Adding additional delay to ensure DNS propagation..." - sleep 5 - fi - - # Create new tunnel - echo "Creating new tunnel named $TUNNEL_NAME..." - local TUNNEL_ID=$(cloudflared tunnel create "$TUNNEL_NAME" 2>&1 | grep -o '[a-f0-9]\{8\}-[a-f0-9]\{4\}-[a-f0-9]\{4\}-[a-f0-9]\{4\}-[a-f0-9]\{12\}' | head -n 1) - - if [ -z "$TUNNEL_ID" ]; then - echo "Failed to create tunnel or capture tunnel ID" - exit 1 - fi - - echo "Tunnel ID: $TUNNEL_ID" - - echo "Generating configuration file..." - - # Auto-detect if local service is HTTPS by attempting a connection - local detected_protocol="http" - echo "Detecting local service protocol on port $LOCAL_PORT..." - - # Try to detect if the service is HTTPS by checking if it responds to HTTPS - if timeout 3 openssl s_client -connect localhost:$LOCAL_PORT -servername localhost /dev/null 2>&1; then - detected_protocol="https" - echo "✓ Detected HTTPS service on port $LOCAL_PORT" - elif timeout 3 curl -s -k https://localhost:$LOCAL_PORT >/dev/null 2>&1; then - detected_protocol="https" - echo "✓ Detected HTTPS service on port $LOCAL_PORT (via curl)" - else - echo "✓ Detected HTTP service on port $LOCAL_PORT" - fi - - # Use detected protocol if not explicitly specified - if [ "$PROTOCOL" = "http" ] && [ "$detected_protocol" = "https" ]; then - PROTOCOL="https" - echo "→ Automatically switching to HTTPS mode for HTTPS service" - fi - - # Build the configuration based on detected/specified protocol - if [ "$PROTOCOL" = "https" ]; then - echo "Configuring for HTTPS backend service..." - if [ "$IGNORE_CERT" = "true" ]; then - cat > "$CONFIG_FILE" << EOF -url: https://localhost:$LOCAL_PORT -tunnel: $TUNNEL_ID -credentials-file: $CLOUDFLARED_DIR/$TUNNEL_ID.json -originRequest: - noTLSVerify: true - disableChunkedEncoding: true -EOF - else - cat > "$CONFIG_FILE" << EOF -url: https://localhost:$LOCAL_PORT -tunnel: $TUNNEL_ID -credentials-file: $CLOUDFLARED_DIR/$TUNNEL_ID.json -originRequest: - disableChunkedEncoding: true -EOF - fi - else - echo "Configuring for HTTP backend service..." - cat > "$CONFIG_FILE" << EOF -url: http://localhost:$LOCAL_PORT -tunnel: $TUNNEL_ID -credentials-file: $CLOUDFLARED_DIR/$TUNNEL_ID.json -EOF - fi - - echo "Config file generated at $CONFIG_FILE" - echo "Configuration contents:" - cat "$CONFIG_FILE" - - # Create DNS route - echo "Creating DNS route..." - if ! cloudflared tunnel route dns "$TUNNEL_NAME" "$FULL_DOMAIN" 2>&1; then - echo "Warning: DNS route creation failed. This might be because:" - echo "1. The DNS record already exists and wasn't properly cleaned up" - echo "2. DNS propagation is still in progress" - echo "Attempting to continue with tunnel startup..." - echo "If the tunnel doesn't work, try running the command again in a few minutes." - sleep 3 - else - echo "DNS route created successfully." - fi - - # Run the tunnel - echo "Starting the tunnel..." - echo "Your service will be accessible at https://$FULL_DOMAIN" - if [ "$PROTOCOL" = "https" ]; then - echo "→ Tunneling HTTPS traffic from localhost:$LOCAL_PORT" - if [ "$IGNORE_CERT" = "true" ]; then - echo "→ SSL certificate verification is disabled for local HTTPS service" - else - echo "→ SSL certificate verification is enabled" - fi - else - echo "→ Tunneling HTTP traffic from localhost:$LOCAL_PORT" - fi - echo "Press Ctrl+C to stop the tunnel" - - echo "Executing command: cloudflared tunnel --config $CONFIG_FILE run $TUNNEL_NAME --loglevel debug" - cloudflared tunnel --config $CONFIG_FILE run $TUNNEL_NAME -} -# Main script -main() { - -if [[ "$1" != "-h" && "$1" != "--help" && "$1" != "-v" && "$1" != "--version" ]]; then - if ! validate_basic_requirements; then - exit 1 - fi -fi - - case "$1" in - -h|--help) - show_help - exit 0 - ;; - -v|--version) - echo "cfex version $VERSION" - exit 0 - ;; - list) - list_tunnels - exit 0 - ;; - delete) - if [ -z "$2" ]; then - echo "Error: Please specify a domain to delete" - echo "Usage: cfex delete " - exit 1 - fi - delete_tunnel "$@" - exit 0 - ;; - *) - local FULL_DOMAIN - local LOCAL_PORT - local PROTOCOL="http" - local IGNORE_CERT="true" # Default to true (ignore cert errors) - - # Parse arguments for protocol and cert verification options - local args=("$@") - for i in "${!args[@]}"; do - case "${args[i]}" in - --https) - PROTOCOL="https" - unset args[i] - ;; - --verify-cert) - IGNORE_CERT="false" - unset args[i] - ;; - esac - done - - # Rebuild args array without the flags - local clean_args=() - for arg in "${args[@]}"; do - if [ -n "$arg" ]; then - clean_args+=("$arg") - fi - done - - # Handle domain:port syntax - if [[ "${clean_args[0]}" =~ ^([^:]+):([0-9]+)$ ]]; then - FULL_DOMAIN="${BASH_REMATCH[1]}" - LOCAL_PORT="${BASH_REMATCH[2]}" - else - # Original syntax check - if [ ${#clean_args[@]} -ne 2 ]; then - echo "Error: Invalid arguments" - show_help - exit 1 - fi - FULL_DOMAIN="${clean_args[0]}" - LOCAL_PORT="${clean_args[1]}" - fi - - create_tunnel "$FULL_DOMAIN" "$LOCAL_PORT" "$PROTOCOL" "$IGNORE_CERT" - ;; - esac -} - -# Run main function -main "$@" diff --git a/cmd/cfex/add.go b/cmd/cfex/add.go index 87a0252..5a9ac54 100644 --- a/cmd/cfex/add.go +++ b/cmd/cfex/add.go @@ -1,131 +1,165 @@ package main import ( - "crypto/rand" - "encoding/base64" "fmt" "os" "strconv" "time" - "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfd" "github.com/muthuishere/cfex-cli/internal/cfg" "github.com/muthuishere/cfex-cli/internal/ingress" ) -func cmdAdd(c *cfg.Config, args []string) error { - prot := false +type addFlags struct { + Protected, HTTPS, VerifyTLS bool + Port int + Host string +} + +func parseAdd(c *cfg.Config, args []string) (addFlags, error) { + var a addFlags pos := []string{} - for _, a := range args { - if a == "--protected" { - prot = true - } else { - pos = append(pos, a) + for _, x := range args { + switch x { + case "--protected": + a.Protected = true + case "--https": + a.HTTPS = true + case "--verify-cert": + a.VerifyTLS = true + default: + pos = append(pos, x) } } if len(pos) != 2 { - return fmt.Errorf("usage: cfex add [--protected]") + return a, fmt.Errorf("usage: cfex add [--https] [--verify-cert] [--protected]") } port, err := strconv.Atoi(pos[0]) if err != nil || port < 1 || port > 65535 { - return fmt.Errorf("bad port %q", pos[0]) + return a, fmt.Errorf("bad port %q", pos[0]) } host, err := normHost(c, pos[1]) if err != nil { - return err - } - cl, err := client(c) - if err != nil { - return err + return a, err } - acct, err := pickAccount(c, cl) + a.Port, a.Host = port, host + return a, nil +} + +// cmdAdd: durable route. One locally-managed tunnel owned by cfex (cloudflared tunnel create), the CNAME through +// `cloudflared tunnel route dns` (never overwrites), the ingress rule in tunnel.yml, kept running by launchd/systemd. +func cmdAdd(c *cfg.Config, args []string) error { + a, err := parseAdd(c, args) if err != nil { return err } - c.AccountID = acct - if c.Route(host) != nil { - return fmt.Errorf("%s is already a cfex route (use stop/start/delete)", host) - } - zones, err := cl.Zones() - if err != nil { - return err + if c.Route(a.Host) != nil { + return fmt.Errorf("%s is already a cfex route (use stop/start/delete)", a.Host) } - z, err := zoneFor(zones, host) - if err != nil { + if err := ensureLogin(); err != nil { return err } - if ex, err := cl.DNSByName(z.ID, host); err != nil { + if err := validateZone(c, a.Host); err != nil { return err - } else if len(ex) > 0 { - return fmt.Errorf("%s already has a %s DNS record that cfex did not create: refusing to touch it", host, ex[0].Type) } - if !listening(port) { - fmt.Fprintf(os.Stderr, "warning: nothing is listening on 127.0.0.1:%d yet\n", port) + if !listening(a.Port) { + fmt.Fprintf(os.Stderr, "warning: nothing is listening on 127.0.0.1:%d yet\n", a.Port) } - if err := ensureTunnel(c, cl); err != nil { + if err := ensureTunnel(c); err != nil { return err } - full, rules, err := cl.TunnelConfig(acct, c.TunnelID) + rules, err := loadRules() if err != nil { return err } - next, err := ingress.Add(rules, host, "http://127.0.0.1:"+strconv.Itoa(port)) + next, err := ingress.Add(rules, a.Host, serviceURL(a.HTTPS, a.Port)) if err != nil { return err } - rec, err := cl.DNSCreate(z.ID, cf.DNS{Type: "CNAME", Name: host, Content: c.TunnelID + ".cfargotunnel.com", Proxied: true, Comment: comment}) - if err != nil { + for _, r := range next { + if h, _ := r["hostname"].(string); h == a.Host { + for k, v := range routeRule(a.Host, serviceURL(a.HTTPS, a.Port), a.HTTPS, a.VerifyTLS) { + r[k] = v + } + } + } + if err := saveRules(c.TunnelID, next); err != nil { return err } - if err := cl.PutIngress(acct, c.TunnelID, full, next); err != nil { - _ = cl.DNSDelete(z.ID, rec.ID) // roll back the record we just made + if err := cfd.RouteDNS(c.TunnelName, a.Host); err != nil { // refuses to overwrite an existing record + saveRules(c.TunnelID, rules) return err } - c.Routes = append(c.Routes, cfg.Route{Host: host, Port: port, Zone: z.Name, ZoneID: z.ID, DNSID: rec.ID, - Protected: prot, State: "running", Created: time.Now().Format(time.RFC3339), TunnelID: c.TunnelID}) + route := cfg.Route{Host: a.Host, Port: a.Port, HTTPS: a.HTTPS, VerifyTLS: a.VerifyTLS, Protected: a.Protected, + State: "running", Created: time.Now().Format(time.RFC3339), TunnelID: c.TunnelID} + stampOwnership(c, &route) + c.Routes = append(c.Routes, route) if err := c.Save(); err != nil { return err } - fmt.Printf("routed https://%s -> 127.0.0.1:%d on tunnel %s\n", host, port, c.TunnelName) + if err := serviceReload(c); err != nil { + return err + } + fmt.Printf("routed https://%s -> 127.0.0.1:%d on tunnel %s\n", a.Host, a.Port, c.TunnelName) st := "" for i := 0; i < 24; i++ { // DNS + edge can take a little while - if st = httpStatus("https://" + host); st != "err" && st != "530" && st != "404" && st != "502" { + if st = httpStatus("https://" + a.Host); st != "err" && st != "530" && st != "404" && st != "502" && st != "1033" { break } time.Sleep(5 * time.Second) } - fmt.Printf("live check: https://%s -> HTTP %s\n", host, st) + fmt.Printf("live check: https://%s -> HTTP %s\n", a.Host, st) return nil } -// ensureTunnel creates cfex's OWN named tunnel (and its service) on first use. -func ensureTunnel(c *cfg.Config, cl *cf.Client) error { - fresh := false +// stampOwnership uses the optional API to mark the CNAME cloudflared just made as ours and remember its id. +func stampOwnership(c *cfg.Config, r *cfg.Route) { + cl := apiClient(c) + if cl == nil { + fmt.Fprintln(os.Stderr, "note:", noTokenHint) + return + } + zones, err := cl.Zones() + if err != nil { + return + } + z, err := zoneFor(zones, r.Host) + if err != nil { + return + } + recs, err := cl.DNSByName(z.ID, r.Host) + if err != nil { + return + } + for _, d := range recs { + if d.Type == "CNAME" && d.Content == c.TunnelID+".cfargotunnel.com" { + if cl.DNSSetComment(z.ID, d.ID, comment) == nil { + r.Zone, r.ZoneID, r.DNSID = z.Name, z.ID, d.ID + } + } + } +} + +// ensureTunnel creates cfex's OWN tunnel with cloudflared on first use and installs its service. +func ensureTunnel(c *cfg.Config) error { if c.TunnelID == "" { - ts, err := cl.Tunnels(c.AccountID) + t, err := cfd.Find(c.TunnelName) if err != nil { return err } - for _, t := range ts { - if t.Name == c.TunnelName { - c.TunnelID = t.ID - } - } - if c.TunnelID == "" { - sec := make([]byte, 32) - rand.Read(sec) - t, err := cl.CreateTunnel(c.AccountID, c.TunnelName, base64.StdEncoding.EncodeToString(sec)) + if t != nil { + c.TunnelID = t.ID + } else { + id, err := cfd.Create(c.TunnelName) if err != nil { return err } - c.TunnelID, fresh = t.ID, true + c.TunnelID = id } if err := c.Save(); err != nil { return err } } - if err := storeToken(c, cl, fresh); err != nil { - return err - } return installService(c) } diff --git a/cmd/cfex/common.go b/cmd/cfex/common.go index 2d346ed..acfde6b 100644 --- a/cmd/cfex/common.go +++ b/cmd/cfex/common.go @@ -5,26 +5,18 @@ import ( "fmt" "net" "net/http" + "os" "path" "strings" "time" "github.com/muthuishere/cfex-cli/internal/cf" "github.com/muthuishere/cfex-cli/internal/cfg" - "github.com/muthuishere/cfex-cli/internal/ingress" ) // comment is stamped on every DNS record cfex creates; delete refuses records without it. const comment = "managed-by: cfex" -// API is the slice of the Cloudflare client the mutating commands need (a fake in tests). -type API interface { - DNSGet(zone, id string) (cf.DNS, error) - DNSDelete(zone, id string) error - TunnelConfig(acct, tid string) (map[string]any, []ingress.Rule, error) - PutIngress(acct, tid string, full map[string]any, rules []ingress.Rule) error -} - func matchAny(patterns []string, name string) bool { for _, p := range patterns { if ok, _ := path.Match(p, name); ok { @@ -34,7 +26,7 @@ func matchAny(patterns []string, name string) bool { return false } -// classify says whether a tunnel may be touched. Only "managed" and (via confirmation) "legacy" can be mutated. +// classify says whether a tunnel may be touched. Only "managed" routes and "instant" tunnels (made by `cfex host:port`) can be mutated. func classify(c *cfg.Config, tunnel, id string) string { switch { case tunnel == c.TunnelName || (id != "" && id == c.TunnelID): @@ -43,15 +35,14 @@ func classify(c *cfg.Config, tunnel, id string) string { return "PROTECTED" case matchAny(c.Client, tunnel): return "client" + case strings.HasPrefix(tunnel, instantPrefix): + return "instant" } for _, a := range c.Adopted { if a.Tunnel == tunnel { return "adopted" } } - if strings.HasPrefix(tunnel, "tunnel_") { - return "legacy" // created by cfex v1 (`cfex host:port`) - } return "unmanaged" } @@ -110,14 +101,16 @@ func listening(port int) bool { return true } -func client(c *cfg.Config) (*cf.Client, error) { - t := token(c) - if t == "" { - return nil, fmt.Errorf("no Cloudflare API token: set CLOUDFLARE_API_TOKEN (create one with Zone:DNS:Edit, Zone:Read and Account:Cloudflare Tunnel:Edit) or token_cmd in %s", cfg.Path()) +// apiClient returns the optional Cloudflare API client, or nil when no token is available. +func apiClient(c *cfg.Config) *cf.Client { + if t := token(c); t != "" { + return cf.New(t) } - return cf.New(t), nil + return nil } +const noTokenHint = "no API token: set CLOUDFLARE_API_TOKEN (Zone:Read, DNS:Edit) to enable DNS ownership checks and DNS deletion" + // pickAccount uses account_id from config; otherwise the token's only account. Several accounts: the user must choose. func pickAccount(c *cfg.Config, cl *cf.Client) (string, error) { if c.AccountID != "" { @@ -139,3 +132,32 @@ func pickAccount(c *cfg.Config, cl *cf.Client) (string, error) { } return "", fmt.Errorf("token sees several accounts, set account_id in %s: %s", cfg.Path(), strings.Join(names, ", ")) } + +// validateZone, when an API token is available, checks that the host's domain is visible to it and explains the usual +// causes if not. Without a token the check is skipped (cloudflared reports problems itself). +func validateZone(c *cfg.Config, host string) error { + cl := apiClient(c) + if cl == nil { + return nil + } + zones, err := cl.Zones() + if err == nil { + _, err = zoneFor(zones, host) + } + if err != nil { + return fmt.Errorf("could not fetch zone data for %s: %v\nPossible issues:\n1. Invalid API token\n2. The domain is not in your Cloudflare account\n3. The API token has no permission for this zone", host, err) + } + return nil +} + +// isTTY reports whether stdin is an interactive terminal (a variable so tests can force either answer). +var isTTY = func() bool { + fi, err := os.Stdin.Stat() + if err != nil || fi.Mode()&os.ModeCharDevice == 0 { + return false + } + if null, err := os.Stat(os.DevNull); err == nil && os.SameFile(fi, null) { + return false + } + return true +} diff --git a/cmd/cfex/delete.go b/cmd/cfex/delete.go index 86e1f17..7baaf4d 100644 --- a/cmd/cfex/delete.go +++ b/cmd/cfex/delete.go @@ -2,64 +2,102 @@ package main import ( "bufio" - "flag" "fmt" "io" "os" "strings" + "time" + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfd" "github.com/muthuishere/cfex-cli/internal/cfg" "github.com/muthuishere/cfex-cli/internal/ingress" ) +// cmdDelete: `cfex delete ...`. On a terminal it lists what will go and asks once (y/N); --yes skips the question +// (scripts); without a terminal and without --yes it only prints the plan (dry run). func cmdDelete(c *cfg.Config, args []string) error { - fs := flag.NewFlagSet("delete", flag.ExitOnError) - yes := fs.Bool("yes", false, "apply without asking (durable routes: default is a dry run)") - force := fs.Bool("force", false, "required to delete a PROTECTED managed route") + yes, force := false, false hosts := []string{} - rest := []string{} for _, a := range args { - if strings.HasPrefix(a, "-") { - rest = append(rest, a) - } else { + switch a { + case "--yes", "-y": + yes = true + case "--force": + force = true + default: + if strings.HasPrefix(a, "-") { + return fmt.Errorf("unknown option %s", a) + } hosts = append(hosts, a) } } - fs.Parse(rest) if len(hosts) == 0 { - return fmt.Errorf("usage: cfex delete ... [--yes]") + return fmt.Errorf("no domains provided for deletion\nUsage: cfex delete [domain2] ... [--yes]") } - var legacy []string + type target struct{ host, kind string } + var ts []target + fmt.Println("The following tunnels will be deleted:") for _, h := range hosts { host, err := normHost(c, h) if err != nil { return err } - if c.Route(host) == nil { // not a durable route: maybe a cfex v1 tunnel - legacy = append(legacy, host) - continue - } - if err := protectedGuard(c, host, *force); err != nil { - return err + kind := "missing" + if c.Route(host) != nil { + kind = "route" + if err := protectedGuard(c, host, force); err != nil { + return err + } + } else if t, _ := cfd.Find(instantName(host)); t != nil { + kind = "instant" } - cl, err := client(c) - if err != nil { - return err + if kind == "missing" { + fmt.Printf("- %s (not found)\n", host) + } else { + fmt.Printf("- %s\n", host) } - acct, err := pickAccount(c, cl) - if err != nil { - return err + ts = append(ts, target{host, kind}) + } + apply := yes + if !yes && isTTY() { + if !confirmDelete(os.Stdin, os.Stdout, len(ts)) { + fmt.Println("Operation cancelled.") + return nil } - if err := deleteRoute(cl, c, acct, host, *yes, os.Stdout); err != nil { - return err + apply = true + } + for _, t := range ts { + fmt.Println("----------------------------------------") + switch t.kind { + case "missing": + fmt.Printf("Skipping %s: no tunnel found (not a cfex route and no instant tunnel)\n", t.host) + case "route": + if err := deleteRoute(&realRoute{c: c}, c, t.host, apply, os.Stdout); err != nil { + return err + } + case "instant": + if err := deleteInstant(&realInstant{c: c, api: apiClient(c)}, t.host, apply, os.Stdout); err != nil { + return err + } } } - if len(legacy) > 0 { - return legacyDelete(c, legacy, *yes, bufio.NewReader(os.Stdin)) + if apply { + fmt.Println("Deletion process completed.") + } else { + fmt.Println("Dry run only (no terminal to ask on). Re-run with --yes to apply.") } return nil } +// confirmDelete asks the single y/N question for all targets, like v1 did. +func confirmDelete(in io.Reader, out io.Writer, n int) bool { + fmt.Fprintf(out, "Are you sure you want to delete these %d tunnels? (y/N) ", n) + ans, _ := bufio.NewReader(in).ReadString('\n') + a := strings.ToLower(strings.TrimSpace(ans)) + return a == "y" || a == "yes" +} + func protectedGuard(c *cfg.Config, host string, force bool) error { if r := c.Route(host); r != nil && r.Protected && !force { return fmt.Errorf("%s is a protected route: deleting it needs --force", r.Host) @@ -67,15 +105,38 @@ func protectedGuard(c *cfg.Config, host string, force bool) error { return nil } -// deleteRoute removes the ingress rule + the DNS record of a durable (managed) route. It refuses anything cfex did -// not create (not in config, or a DNS record without the managed-by comment). -func deleteRoute(api API, c *cfg.Config, acct, host string, apply bool, out io.Writer) error { +// routeOps is what deleting a durable route needs (a fake in tests). +type routeOps interface { + HasAPI() bool + DNSGet(zone, id string) (cf.DNS, error) + DNSDelete(zone, id string) error + LoadRules() ([]ingress.Rule, error) + SaveRules(id string, rules []ingress.Rule) error + Reload() error +} + +type realRoute struct{ c *cfg.Config } + +func (r *realRoute) HasAPI() bool { return apiClient(r.c) != nil } +func (r *realRoute) DNSGet(z, id string) (cf.DNS, error) { return apiClient(r.c).DNSGet(z, id) } +func (r *realRoute) DNSDelete(z, id string) error { return apiClient(r.c).DNSDelete(z, id) } +func (r *realRoute) LoadRules() ([]ingress.Rule, error) { return loadRules() } +func (r *realRoute) SaveRules(id string, rules []ingress.Rule) error { + return saveRules(id, rules) +} +func (r *realRoute) Reload() error { return serviceReload(r.c) } + +// deleteRoute removes the ingress rule + the DNS record of a durable route. It refuses anything cfex did not create +// (not in config, or, when the API is available, a DNS record without the managed-by comment). Without an API token the +// ingress rule is removed and the CNAME is left for the user to delete. +func deleteRoute(o routeOps, c *cfg.Config, host string, apply bool, out io.Writer) error { r := c.Route(host) if r == nil { return fmt.Errorf("%s is not a cfex-managed route: refusing (cfex only deletes what it created)", host) } - if r.DNSID != "" { - rec, err := api.DNSGet(r.ZoneID, r.DNSID) + dnsKnown := o.HasAPI() && r.DNSID != "" + if dnsKnown { + rec, err := o.DNSGet(r.ZoneID, r.DNSID) if err != nil { return fmt.Errorf("cannot verify DNS record: %w", err) } @@ -83,7 +144,7 @@ func deleteRoute(api API, c *cfg.Config, acct, host string, apply bool, out io.W return fmt.Errorf("DNS record %s lacks the %q comment: refusing to delete", host, comment) } } - full, rules, err := api.TunnelConfig(acct, r.TunnelID) + rules, err := o.LoadRules() if err != nil { return err } @@ -98,9 +159,12 @@ func deleteRoute(api API, c *cfg.Config, acct, host string, apply bool, out io.W } else { fmt.Fprintf(out, " - ingress rule already absent (route is stopped)\n") } - fmt.Fprintf(out, " - delete DNS record %s (id %s, managed-by cfex)\n", host, r.DNSID) + if dnsKnown { + fmt.Fprintf(out, " - delete DNS record %s (id %s, managed-by cfex)\n", host, r.DNSID) + } else { + fmt.Fprintf(out, " - DNS record %s cannot be deleted without an API token (%s); delete that CNAME yourself\n", host, noTokenHint) + } if !apply { - fmt.Fprintln(out, "dry run only; re-run with --yes to apply") return nil } if has { @@ -108,12 +172,12 @@ func deleteRoute(api API, c *cfg.Config, acct, host string, apply bool, out io.W if err != nil { return err } - if err := api.PutIngress(acct, r.TunnelID, full, next); err != nil { + if err := o.SaveRules(c.TunnelID, next); err != nil { return err } } - if r.DNSID != "" { - if err := api.DNSDelete(r.ZoneID, r.DNSID); err != nil { + if dnsKnown { + if err := o.DNSDelete(r.ZoneID, r.DNSID); err != nil { return err } } @@ -121,6 +185,43 @@ func deleteRoute(api API, c *cfg.Config, acct, host string, apply bool, out io.W if err := c.Save(); err != nil { return err } + if has { + if err := o.Reload(); err != nil { + return err + } + } fmt.Fprintln(out, "deleted.") return nil } + +type instantDeleteOps interface { + Find(name string) (*cfd.Tunnel, error) + DeleteDNS(host, tunnelID string) (bool, error) + DeleteTunnel(name string) error +} + +func deleteInstant(o instantDeleteOps, host string, apply bool, out io.Writer) error { + name := instantName(host) + t, err := o.Find(name) + if err != nil || t == nil { + return fmt.Errorf("no instant tunnel named %s", name) + } + if !apply { + fmt.Fprintf(out, "DRY RUN: delete instant tunnel %s (id %s) and its DNS record for %s\n", name, t.ID, host) + return nil + } + fmt.Fprintf(out, "Processing deletion for %s...\n", host) + switch done, err := o.DeleteDNS(host, t.ID); { + case err != nil: + fmt.Fprintf(out, "could not delete the DNS record: %v\n", err) + case !done: + fmt.Fprintf(out, "DNS record left in place (%s); delete the CNAME %s yourself\n", noTokenHint, host) + } + fmt.Fprintln(out, "Cleaning up existing connections and deleting the tunnel...") + if err := o.DeleteTunnel(name); err != nil { + return fmt.Errorf("failed to delete tunnel for %s: %w", host, err) + } + waitUntil(60*time.Second, 2*time.Second, "tunnel deletion", func() bool { t, _ := o.Find(name); return t == nil }) + fmt.Fprintf(out, "Tunnel for %s has been deleted.\n", host) + return nil +} diff --git a/cmd/cfex/delete_test.go b/cmd/cfex/delete_test.go index 9309dd4..220a026 100644 --- a/cmd/cfex/delete_test.go +++ b/cmd/cfex/delete_test.go @@ -10,29 +10,31 @@ import ( "github.com/muthuishere/cfex-cli/internal/ingress" ) -type fake struct { +type fakeRoute struct { + api bool dns cf.DNS rules []ingress.Rule - puts int + saved int deleted []string + reloads int } -func (f *fake) DNSGet(z, id string) (cf.DNS, error) { return f.dns, nil } -func (f *fake) DNSDelete(z, id string) error { f.deleted = append(f.deleted, id); return nil } -func (f *fake) TunnelConfig(a, t string) (map[string]any, []ingress.Rule, error) { - return map[string]any{}, f.rules, nil -} -func (f *fake) PutIngress(a, t string, full map[string]any, r []ingress.Rule) error { - f.puts++ +func (f *fakeRoute) HasAPI() bool { return f.api } +func (f *fakeRoute) DNSGet(z, id string) (cf.DNS, error) { return f.dns, nil } +func (f *fakeRoute) DNSDelete(z, id string) error { f.deleted = append(f.deleted, id); return nil } +func (f *fakeRoute) LoadRules() ([]ingress.Rule, error) { return f.rules, nil } +func (f *fakeRoute) Reload() error { f.reloads++; return nil } +func (f *fakeRoute) SaveRules(id string, r []ingress.Rule) error { + f.saved++ f.rules = r return nil } -func setup(t *testing.T) (*cfg.Config, *fake) { +func setup(t *testing.T) (*cfg.Config, *fakeRoute) { t.Setenv("CFEX_CONFIG", t.TempDir()+"/config.yaml") c := cfg.Default() c.Routes = []cfg.Route{{Host: "t.example.com", Port: 9, ZoneID: "z", DNSID: "d1", TunnelID: "T", State: "running"}} - f := &fake{dns: cf.DNS{Comment: comment}, rules: []ingress.Rule{ + f := &fakeRoute{api: true, dns: cf.DNS{Comment: comment}, rules: []ingress.Rule{ {"hostname": "other.example.com", "service": "http://127.0.0.1:1"}, {"hostname": "t.example.com", "service": "http://127.0.0.1:9"}, {"service": ingress.CatchAll}}} @@ -42,43 +44,60 @@ func setup(t *testing.T) (*cfg.Config, *fake) { func TestDeleteDryRunChangesNothing(t *testing.T) { c, f := setup(t) var out bytes.Buffer - if err := deleteRoute(f, c, "a", "t.example.com", false, &out); err != nil { + if err := deleteRoute(f, c, "t.example.com", false, &out); err != nil { t.Fatal(err) } - if f.puts != 0 || len(f.deleted) != 0 || c.Route("t.example.com") == nil { - t.Fatal("dry run mutated something") - } - if !strings.Contains(out.String(), "DRY RUN") { - t.Fatal(out.String()) + if f.saved != 0 || len(f.deleted) != 0 || c.Route("t.example.com") == nil || !strings.Contains(out.String(), "DRY RUN") { + t.Fatal("dry run mutated something:", out.String()) } } func TestDeleteRefusesUnmanagedHost(t *testing.T) { c, f := setup(t) - if err := deleteRoute(f, c, "a", "os.example.com", true, &bytes.Buffer{}); err == nil || f.puts != 0 || len(f.deleted) != 0 { - t.Fatalf("must refuse a route cftunnel did not create: %v", err) + if err := deleteRoute(f, c, "os.example.com", true, &bytes.Buffer{}); err == nil || f.saved != 0 || len(f.deleted) != 0 { + t.Fatalf("must refuse a route cfex did not create: %v", err) } } func TestDeleteRefusesDNSWithoutManagedComment(t *testing.T) { c, f := setup(t) f.dns.Comment = "someone else's" - if err := deleteRoute(f, c, "a", "t.example.com", true, &bytes.Buffer{}); err == nil || len(f.deleted) != 0 { + if err := deleteRoute(f, c, "t.example.com", true, &bytes.Buffer{}); err == nil || len(f.deleted) != 0 || f.saved != 0 { t.Fatalf("must refuse DNS record lacking the comment: %v", err) } } func TestDeleteApplyLeavesOtherRoutes(t *testing.T) { c, f := setup(t) - if err := deleteRoute(f, c, "a", "t.example.com", true, &bytes.Buffer{}); err != nil { + if err := deleteRoute(f, c, "t.example.com", true, &bytes.Buffer{}); err != nil { t.Fatal(err) } - if len(f.deleted) != 1 || ingress.Has(f.rules, "t.example.com") || !ingress.Has(f.rules, "other.example.com") { + if len(f.deleted) != 1 || ingress.Has(f.rules, "t.example.com") || !ingress.Has(f.rules, "other.example.com") || f.reloads != 1 { t.Fatalf("bad result: %v %v", f.deleted, f.rules) } } -func TestClassifyProtected(t *testing.T) { +func TestDeleteWithoutAPITokenLeavesDNSAndSaysSo(t *testing.T) { + c, f := setup(t) + f.api = false + var out bytes.Buffer + if err := deleteRoute(f, c, "t.example.com", true, &out); err != nil { + t.Fatal(err) + } + if len(f.deleted) != 0 || ingress.Has(f.rules, "t.example.com") || !strings.Contains(out.String(), "delete that CNAME yourself") { + t.Fatal(out.String()) + } +} + +func TestProtectedRouteNeedsForce(t *testing.T) { + c, _ := setup(t) + c.Routes[0].Protected = true + if protectedGuard(c, "t.example.com", false) == nil || protectedGuard(c, "t.example.com", true) != nil { + t.Fatal("protected route must need --force") + } +} + +func TestClassify(t *testing.T) { c := cfg.Default() c.Protected = []string{"billing", "pay-*"} c.Client = []string{"acme-demo"} @@ -88,18 +107,10 @@ func TestClassifyProtected(t *testing.T) { } } if classify(c, "acme-demo", "") != "client" || classify(c, "random", "") != "unmanaged" || - classify(c, "tunnel_dev_example_com", "") != "legacy" || classify(c, c.TunnelName, "") != "managed" { + classify(c, "cfex-instant-dev-example-com", "") != "instant" || classify(c, c.TunnelName, "") != "managed" { t.Fatal("classes") } if classify(cfg.Default(), "billing", "") != "unmanaged" { t.Fatal("nothing is protected unless the user's config says so") } } - -func TestProtectedRouteNeedsCeoGo(t *testing.T) { - c, _ := setup(t) - c.Routes[0].Protected = true - if err := protectedGuard(c, "t.example.com", false); err == nil { - t.Fatal("protected route must need --ceo-go") - } -} diff --git a/cmd/cfex/diag.go b/cmd/cfex/diag.go index 8e97998..2b3e219 100644 --- a/cmd/cfex/diag.go +++ b/cmd/cfex/diag.go @@ -4,9 +4,9 @@ import ( "flag" "fmt" "os" - "os/exec" "strings" + "github.com/muthuishere/cfex-cli/internal/cfd" "github.com/muthuishere/cfex-cli/internal/cfg" "github.com/muthuishere/cfex-cli/internal/disc" "github.com/muthuishere/cfex-cli/internal/ingress" @@ -23,19 +23,6 @@ func cmdStatus(c *cfg.Config, args []string) error { } fmt.Printf("routes %d (%d stopped)\n", len(c.Routes), n) fmt.Printf("cloudflared %d processes on this machine\n", disc.RunningCloudflaredCount()) - if c.TunnelID != "" { - if cl, err := client(c); err == nil { - if acct, err := pickAccount(c, cl); err == nil { - if ts, err := cl.Tunnels(acct); err == nil { - for _, t := range ts { - if t.ID == c.TunnelID { - fmt.Printf("api %s, %d connections\n", t.Status, len(t.Connections)) - } - } - } - } - } - } return nil } @@ -46,6 +33,12 @@ func orDash(s string) string { return s } +const tokenHelp = `Create an API token at https://dash.cloudflare.com/profile/api-tokens + 1. Click 'Create Token' and use the 'Edit zone DNS' template + 2. Under 'Zone Resources' choose 'Include Specific Zone' and pick your domain + 3. Export it: export CLOUDFLARE_API_TOKEN='your-api-token' (CLOUDFLARE_API_KEY also works) + For persistence, add that line to ~/.zshrc or ~/.bashrc.` + func cmdDoctor(c *cfg.Config, args []string) error { bad := 0 check := func(ok bool, what, detail string) { @@ -56,36 +49,39 @@ func cmdDoctor(c *cfg.Config, args []string) error { } fmt.Printf("[%s] %s %s\n", m, what, detail) } - if p, err := exec.LookPath("cloudflared"); err == nil { - v, _ := exec.Command(p, "--version").Output() - check(true, "cloudflared installed", strings.TrimSpace(string(v))) - } else { - check(false, "cloudflared installed", "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") - } - cl, cerr := client(c) + _, cerr := cfd.Bin() + check(cerr == nil, "cloudflared installed", cfd.Version()) if cerr != nil { - check(false, "Cloudflare API token available", cerr.Error()) - return fmt.Errorf("%d check(s) failed", bad) + fmt.Println(" install: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") + } + login := cfd.HasCert() + check(login, "cloudflared logged in (needed for: instant tunnels, add, list, delete)", cfd.CertPath()) + if !login { + fmt.Println(" fix: cloudflared tunnel login (pick the domain you want to tunnel)") + } + api := apiClient(c) + if api == nil { + fmt.Println("[info] no API token (optional). Without it cfex cannot: verify/stamp DNS ownership, delete DNS records, read remote ingress, validate zones.") + fmt.Println(indent(tokenHelp)) + } else { + _, err := pickAccount(c, api) + check(err == nil, "Cloudflare API token works (enables DNS ownership checks, DNS deletion, remote ingress)", errString(err)) } - check(true, "Cloudflare API token available", "") - acct, aerr := pickAccount(c, cl) - check(aerr == nil, "Cloudflare account reachable", fmt.Sprint(aerr)) if c.TunnelID == "" { - fmt.Println("[info] cfex's own tunnel is created on the first `cfex add`") - } else if aerr == nil { + fmt.Println("[info] cfex's durable tunnel is created on the first `cfex add`") + } else { check(serviceLoaded(c), "service "+c.ServiceLabel+" running", "") - _, rules, err := cl.TunnelConfig(acct, c.TunnelID) - check(err == nil, "ingress readable", fmt.Sprint(err)) - zs, _ := cl.Zones() + rules, err := loadRules() + check(err == nil, "tunnel.yml readable", errString(err)) for _, r := range c.Routes { if r.State == "stopped" { continue } svc, has := ingress.Service(rules, r.Host) check(has, "ingress rule for "+r.Host, svc) - if z, err := zoneFor(zs, r.Host); err == nil { - recs, _ := cl.DNSByName(z.ID, r.Host) - check(len(recs) == 1 && strings.HasPrefix(recs[0].Content, c.TunnelID), "DNS -> tunnel for "+r.Host, "") + if api != nil && r.DNSID != "" { + d, err := api.DNSGet(r.ZoneID, r.DNSID) + check(err == nil && strings.HasPrefix(d.Content, c.TunnelID), "DNS -> tunnel for "+r.Host, errString(err)) } } } @@ -99,6 +95,15 @@ func cmdDoctor(c *cfg.Config, args []string) error { return nil } +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func indent(s string) string { return " " + strings.ReplaceAll(s, "\n", "\n ") } + // cmdImport adopts an existing tunnel into config WITHOUT changing what it serves: read-only discovery, a report, // then a config record (config only; no API write, no service change). func cmdImport(c *cfg.Config, args []string) error { @@ -117,15 +122,7 @@ func cmdImport(c *cfg.Config, args []string) error { if target == "" { return fmt.Errorf("usage: cfex import [--dry-run]") } - cl, err := client(c) - if err != nil { - return err - } - acct, err := pickAccount(c, cl) - if err != nil { - return err - } - ts, err := cl.Tunnels(acct) + ts, err := allTunnels(c) if err != nil { return err } @@ -147,9 +144,9 @@ func cmdImport(c *cfg.Config, args []string) error { return fmt.Errorf("no account tunnel matches %q (use the tunnel name from `cfex list`)", target) } found := ts[idx] - _, rules, _ := cl.TunnelConfig(acct, found.ID) - fmt.Printf("tunnel %s (%s) status=%s class=%s\n", found.Name, found.ID, found.Status, classify(c, found.Name, found.ID)) - for _, r := range rules { + run := map[bool]string{true: "running", false: "down"}[found.Running] + fmt.Printf("tunnel %s (%s) %s class=%s\n", found.Name, found.ID, run, classify(c, found.Name, found.ID)) + for _, r := range found.Rules { if h, _ := r["hostname"].(string); h != "" { fmt.Printf("route %s -> %v\n", h, r["service"]) } diff --git a/cmd/cfex/instant.go b/cmd/cfex/instant.go new file mode 100644 index 0000000..cfb5864 --- /dev/null +++ b/cmd/cfex/instant.go @@ -0,0 +1,324 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfd" + "github.com/muthuishere/cfex-cli/internal/cfg" +) + +// Instant mode: `cfex dev.example.com:3000` puts a local port on your domain right now, in the foreground. It makes a +// quick named tunnel with cloudflared, routes the hostname, runs until Ctrl+C, then removes the tunnel and the DNS record +// it created. --keep turns it into a durable `cfex add`. + +const instantPrefix = "cfex-instant-" + +func instantName(host string) string { return instantPrefix + strings.ReplaceAll(host, ".", "-") } + +type instantArgs struct { + Host string + Port int + HTTPS bool + VerifyCrt bool + Keep bool +} + +func instantParse(args []string) (instantArgs, error) { + var a instantArgs + pos := []string{} + for _, x := range args { + switch x { + case "--https": + a.HTTPS = true + case "--verify-cert": + a.VerifyCrt = true + case "--keep": + a.Keep = true + default: + if strings.HasPrefix(x, "-") { + return a, fmt.Errorf("unknown option %s\n\n%s", x, usage) + } + pos = append(pos, x) + } + } + var port string + if len(pos) == 1 { + if m := regexp.MustCompile(`^([^:]+):([0-9]+)$`).FindStringSubmatch(pos[0]); m != nil { + a.Host, port = m[1], m[2] + } + } else if len(pos) == 2 { + a.Host, port = pos[0], pos[1] + } + if a.Host == "" { + return a, fmt.Errorf("invalid arguments\n\n%s", usage) + } + p, err := strconv.Atoi(port) + if err != nil { + return a, fmt.Errorf("local_port must be a number") + } + if p < 1 || p > 65535 { + return a, fmt.Errorf("local_port must be between 1 and 65535") + } + a.Port, a.Host = p, strings.ToLower(a.Host) + return a, nil +} + +// instantConfig is the cloudflared config for an instant tunnel (pure, unit-tested). +func instantConfig(host string, port int, tunnelID string, https, verify bool) string { + var b strings.Builder + fmt.Fprintf(&b, "tunnel: %s\ncredentials-file: %s\ningress:\n - hostname: %s\n service: %s\n", tunnelID, cfd.CredentialsFile(tunnelID), host, serviceURL(https, port)) + if https { + b.WriteString(" originRequest:\n") + if !verify { + b.WriteString(" noTLSVerify: true\n") + } + b.WriteString(" disableChunkedEncoding: true\n") + } + b.WriteString(" - service: http_status:404\n") + return b.String() +} + +// detectHTTPS reports whether a local service speaks TLS (certificate errors ignored). +func detectHTTPS(port int) bool { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", fmt.Sprintf("localhost:%d", port), &tls.Config{InsecureSkipVerify: true, ServerName: "localhost"}) + if err != nil { + return false + } + conn.Close() + return true +} + +// instantOps is what an instant session needs (a fake in tests). +type instantOps interface { + Find(name string) (*cfd.Tunnel, error) + Create(name string) (string, error) + RouteDNS(name, host string) error + WriteConfig(id string) (string, error) + Run(ctx context.Context, cfgFile, name string) error // blocks until ctx is done or cloudflared exits + DeleteDNS(host, tunnelID string) (done bool, err error) + DeleteTunnel(name string) error +} + +// instantSession runs the whole life of an instant tunnel and ALWAYS cleans up what it created. +func instantSession(ctx context.Context, ops instantOps, host string, out io.Writer) error { + name := instantName(host) + t, err := ops.Find(name) + if err != nil { + return err + } + var id string + freshTunnel := t == nil + if freshTunnel { + fmt.Fprintln(out, "Creating new tunnel named", name) + if id, err = ops.Create(name); err != nil { + return err + } + } else { + fmt.Fprintln(out, "Found existing tunnel named", name, "(left over from an earlier run); reusing it") + id = t.ID + } + fmt.Fprintln(out, "Tunnel ID:", id) + cleaned := false + cleanup := func() { + if cleaned { + return + } + cleaned = true + fmt.Fprintln(out, "\nCleaning up...") + switch done, err := ops.DeleteDNS(host, id); { + case err != nil: + fmt.Fprintf(out, "could not delete the DNS record for %s: %v\n", host, err) + case done: + fmt.Fprintln(out, "DNS record for", host, "removed") + default: + fmt.Fprintf(out, "DNS record for %s left in place (%s); delete the CNAME %s yourself\n", host, noTokenHint, host) + } + if err := ops.DeleteTunnel(name); err != nil { + fmt.Fprintf(out, "could not delete tunnel %s: %v\n", name, err) + } else { + fmt.Fprintln(out, "Tunnel", name, "removed") + } + } + defer cleanup() + if err := ops.RouteDNS(name, host); err != nil { + if freshTunnel { + return fmt.Errorf("DNS route for %s failed (the hostname may already have a record, or DNS is still propagating): %w", host, err) + } + fmt.Fprintf(out, "Warning: DNS route creation failed (%v). Continuing; if the tunnel does not work, try again in a few minutes.\n", err) + } else { + fmt.Fprintln(out, "DNS route created.") + } + cfgFile, err := ops.WriteConfig(id) + if err != nil { + return err + } + fmt.Fprintf(out, "Your service will be accessible at https://%s\nPress Ctrl+C to stop the tunnel (it is removed on exit; use --keep to make it permanent)\n", host) + err = ops.Run(ctx, cfgFile, name) + if ctx.Err() != nil { + return nil // Ctrl+C is the normal way out + } + return err +} + +type realInstant struct { + c *cfg.Config + a instantArgs + https bool + api *cf.Client +} + +func (r *realInstant) Find(n string) (*cfd.Tunnel, error) { return cfd.Find(n) } +func (r *realInstant) Create(n string) (string, error) { return cfd.Create(n) } +func (r *realInstant) RouteDNS(n, h string) error { return cfd.RouteDNS(n, h) } +func (r *realInstant) DeleteTunnel(n string) error { return cfd.Delete(n) } +func (r *realInstant) WriteConfig(id string) (string, error) { + dir := filepath.Join(cfg.StateDir(), "instant") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + p := filepath.Join(dir, strings.ReplaceAll(r.a.Host, ".", "_")+".yml") + return p, os.WriteFile(p, []byte(instantConfig(r.a.Host, r.a.Port, id, r.https, r.a.VerifyCrt)), 0o600) +} +func (r *realInstant) Run(ctx context.Context, cfgFile, name string) error { + bin, err := cfd.Bin() + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, bin, "tunnel", "--no-autoupdate", "--config", cfgFile, "run", name) + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGINT) } + cmd.WaitDelay = 10 * time.Second + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + +// DeleteDNS removes ONLY the record that points at this tunnel (needs the optional API token). +func (r *realInstant) DeleteDNS(host, tunnelID string) (bool, error) { + if r.api == nil { + return false, nil + } + zones, err := r.api.Zones() + if err != nil { + return false, err + } + z, err := zoneFor(zones, host) + if err != nil { + return false, err + } + recs, err := r.api.DNSByName(z.ID, host) + if err != nil { + return false, err + } + for _, d := range recs { + if d.Type == "CNAME" && d.Content == tunnelID+".cfargotunnel.com" { + if err := r.api.DNSDelete(z.ID, d.ID); err != nil { + return false, err + } + } + } + return waitUntil(60*time.Second, 2*time.Second, "DNS record deletion", func() bool { + left, _ := r.api.DNSByName(z.ID, host) + for _, d := range left { + if d.Content == tunnelID+".cfargotunnel.com" { + return false + } + } + return true + }), nil +} + +// waitUntil polls like v1 did (max 60 s): "Waiting for X..." then "X confirmed", or a timeout notice. +func waitUntil(max, every time.Duration, what string, done func() bool) bool { + fmt.Printf("Waiting for %s to complete...\n", what) + start := time.Now() + for !done() { + if time.Since(start) >= max { + fmt.Printf("Timeout waiting for %s\n", what) + return false + } + time.Sleep(every) + } + fmt.Printf("%s confirmed\n", what) + return true +} + +func cmdInstant(c *cfg.Config, args []string) error { + a, err := instantParse(args) + if err != nil { + return err + } + if a.Keep { // --keep: this is a durable route + fa := []string{strconv.Itoa(a.Port), a.Host} + if a.HTTPS { + fa = append(fa, "--https") + } + if a.VerifyCrt { + fa = append(fa, "--verify-cert") + } + return cmdAdd(c, fa) + } + if err := ensureLogin(); err != nil { + return err + } + if err := validateZone(c, a.Host); err != nil { + return err + } + https := a.HTTPS + if !https && detectHTTPS(a.Port) { + https = true + fmt.Printf("Detected HTTPS service on port %d: switching to HTTPS mode\n", a.Port) + } + if https { + fmt.Printf("Tunneling HTTPS traffic from localhost:%d (certificate verification %s)\n", a.Port, map[bool]string{true: "enabled", false: "disabled"}[a.VerifyCrt]) + } else { + fmt.Printf("Tunneling HTTP traffic from localhost:%d\n", a.Port) + } + if !listening(a.Port) && !https { + fmt.Fprintf(os.Stderr, "warning: nothing is listening on localhost:%d yet\n", a.Port) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return instantSession(ctx, &realInstant{c: c, a: a, https: https, api: apiClient(c)}, a.Host, os.Stdout) +} + +const loginHelp = `cloudflared is not logged in. Authentication: +1. You will be redirected to Cloudflare's login page +2. Log in to your Cloudflare account +3. Select the PRIMARY domain you want to use for tunneling + (the root domain you own, e.g. yourdomain.com) +4. Authorize Tunnel for that domain + +Once authenticated you can create tunnels for any subdomain under that domain.` + +// ensureLogin checks cloudflared and runs the guided `cloudflared tunnel login` when there is no cert.pem. +func ensureLogin() error { + bin, err := cfd.Bin() + if err != nil { + return err + } + if cfd.HasCert() { + return nil + } + fmt.Println(loginHelp) + fmt.Println("\nStarting authentication...") + cmd := exec.Command(bin, "tunnel", "login") + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil || !cfd.HasCert() { + return fmt.Errorf("authentication failed. Please try again (cloudflared tunnel login)") + } + fmt.Println("\nAuthentication successful! You can now create tunnels for your domain and its subdomains.") + return nil +} diff --git a/cmd/cfex/legacy.go b/cmd/cfex/legacy.go deleted file mode 100644 index 6680b4a..0000000 --- a/cmd/cfex/legacy.go +++ /dev/null @@ -1,260 +0,0 @@ -package main - -import ( - "bufio" - "crypto/tls" - "encoding/json" - "fmt" - "net" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "syscall" - "time" - - "github.com/muthuishere/cfex-cli/internal/cf" - "github.com/muthuishere/cfex-cli/internal/cfg" -) - -// cfex v1 behaviour: one cloudflared-managed tunnel per hostname, run in the foreground until Ctrl+C. -// `cfex host:3000`, `cfex host 3000`, --https, --verify-cert, `cfex list`, `cfex delete host` keep working. - -type legacyArgs struct { - Host string - Port int - HTTPS bool - VerifyCrt bool -} - -func legacyParse(args []string) (legacyArgs, error) { - var a legacyArgs - pos := []string{} - for _, x := range args { - switch x { - case "--https": - a.HTTPS = true - case "--verify-cert": - a.VerifyCrt = true - default: - if strings.HasPrefix(x, "-") { - return a, fmt.Errorf("unknown option %s\n\n%s", x, usage) - } - pos = append(pos, x) - } - } - var port string - if len(pos) == 1 { - if m := regexp.MustCompile(`^([^:]+):([0-9]+)$`).FindStringSubmatch(pos[0]); m != nil { - a.Host, port = m[1], m[2] - } - } else if len(pos) == 2 { - a.Host, port = pos[0], pos[1] - } - if a.Host == "" { - return a, fmt.Errorf("invalid arguments\n\n%s", usage) - } - p, err := strconv.Atoi(port) - if err != nil { - return a, fmt.Errorf("local_port must be a number") - } - if p < 1 || p > 65535 { - return a, fmt.Errorf("local_port must be between 1 and 65535") - } - a.Port, a.Host = p, strings.ToLower(a.Host) - return a, nil -} - -func legacyName(host string) string { return "tunnel_" + strings.ReplaceAll(host, ".", "_") } - -func cloudflaredDir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".cloudflared") } - -// legacyConfig is the per-hostname cloudflared config v1 wrote (pure, unit-tested). -func legacyConfig(https bool, port int, tunnelID string, ignoreCert bool) string { - creds := filepath.Join(cloudflaredDir(), tunnelID+".json") - if !https { - return fmt.Sprintf("url: http://localhost:%d\ntunnel: %s\ncredentials-file: %s\n", port, tunnelID, creds) - } - s := fmt.Sprintf("url: https://localhost:%d\ntunnel: %s\ncredentials-file: %s\noriginRequest:\n", port, tunnelID, creds) - if ignoreCert { - s += " noTLSVerify: true\n" - } - return s + " disableChunkedEncoding: true\n" -} - -func detectHTTPS(port int) bool { - d := &net.Dialer{Timeout: 3 * time.Second} - conn, err := tls.DialWithDialer(d, "tcp", fmt.Sprintf("localhost:%d", port), &tls.Config{InsecureSkipVerify: true, ServerName: "localhost"}) - if err != nil { - return false - } - conn.Close() - return true -} - -type ltunnel struct{ ID, Name string } - -func localTunnels() ([]ltunnel, error) { - out, err := exec.Command("cloudflared", "tunnel", "list", "--output", "json").Output() - if err != nil { - return nil, fmt.Errorf("cloudflared tunnel list: %v (run `cloudflared tunnel login` once)", err) - } - var ts []ltunnel - if s := strings.TrimSpace(string(out)); s != "" && s != "null" { - var raw []struct{ ID, Name string } - if err := json.Unmarshal(out, &raw); err != nil { - return nil, err - } - for _, r := range raw { - ts = append(ts, ltunnel(r)) - } - } - return ts, nil -} - -func legacyRun(c *cfg.Config, args []string) error { - a, err := legacyParse(args) - if err != nil { - return err - } - if _, err := exec.LookPath("cloudflared"); err != nil { - return fmt.Errorf("cloudflared is not installed") - } - if _, err := os.Stat(filepath.Join(cloudflaredDir(), "cert.pem")); err != nil { - fmt.Println("cloudflared is not authenticated: opening Cloudflare login. Choose the domain you want to tunnel, then run this command again.") - cmd := exec.Command("cloudflared", "tunnel", "login") - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - cmd.Run() - return fmt.Errorf("authenticate, then re-run") - } - cl, err := client(c) - if err != nil { - return err - } - zones, err := cl.Zones() - if err != nil { - return err - } - z, err := zoneFor(zones, a.Host) - if err != nil { - return err - } - name := legacyName(a.Host) - cfgFile := filepath.Join(cloudflaredDir(), strings.ReplaceAll(a.Host, ".", "_")+"_config.yml") - ts, err := localTunnels() - if err != nil { - return err - } - var id string - for _, t := range ts { - if t.Name == name { - id = t.ID - } - } - if id != "" { - fmt.Println("Found existing tunnel named", name) - exec.Command("cloudflared", "tunnel", "cleanup", name).Run() - } else { - recs, err := cl.DNSByName(z.ID, a.Host) - if err != nil { - return err - } - for _, r := range recs { // an orphan from an earlier tunnel: only tunnel CNAMEs are replaced - if r.Type != "CNAME" || !strings.HasSuffix(r.Content, ".cfargotunnel.com") { - return fmt.Errorf("%s already has a %s record that is not a tunnel: refusing to replace it", a.Host, r.Type) - } - fmt.Println("Removing orphaned tunnel DNS record for", a.Host) - if err := cl.DNSDelete(z.ID, r.ID); err != nil { - return err - } - } - fmt.Println("Creating new tunnel named", name) - out, err := exec.Command("cloudflared", "tunnel", "create", name).CombinedOutput() - id = regexp.MustCompile(`[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}`).FindString(string(out)) - if err != nil || id == "" { - return fmt.Errorf("failed to create tunnel or capture its id: %s", strings.TrimSpace(string(out))) - } - } - fmt.Println("Tunnel ID:", id) - https := a.HTTPS - if !https && detectHTTPS(a.Port) { - https = true - fmt.Printf("Detected HTTPS service on port %d: switching to HTTPS mode\n", a.Port) - } - if err := os.WriteFile(cfgFile, []byte(legacyConfig(https, a.Port, id, !a.VerifyCrt)), 0o600); err != nil { - return err - } - fmt.Println("Config written to", cfgFile) - if out, err := exec.Command("cloudflared", "tunnel", "route", "dns", name, a.Host).CombinedOutput(); err != nil { - fmt.Printf("Warning: DNS route creation failed (%s). Continuing; if the tunnel does not work, try again in a few minutes.\n", strings.TrimSpace(string(out))) - } - fmt.Printf("Your service will be accessible at https://%s\nPress Ctrl+C to stop the tunnel\n", a.Host) - bin, _ := exec.LookPath("cloudflared") - return syscall.Exec(bin, []string{"cloudflared", "tunnel", "--config", cfgFile, "run", name}, os.Environ()) -} - -// legacyDelete removes cfex v1 tunnels (and their tunnel DNS records), with the same confirmation v1 asked for. -func legacyDelete(c *cfg.Config, hosts []string, yes bool, in *bufio.Reader) error { - ts, err := localTunnels() - if err != nil { - return err - } - have := map[string]bool{} - for _, t := range ts { - have[t.Name] = true - } - fmt.Println("The following tunnels will be deleted:") - var todo []string - for _, h := range hosts { - if have[legacyName(h)] { - fmt.Println("-", h) - todo = append(todo, h) - } else { - fmt.Println("-", h, "(not found: neither a cfex route nor a cfex v1 tunnel; nothing will be touched)") - } - } - if len(todo) == 0 { - return fmt.Errorf("nothing to delete") - } - if !yes { - fmt.Printf("Are you sure you want to delete these %d tunnels? (y/N) ", len(todo)) - ans, _ := in.ReadString('\n') - if a := strings.ToLower(strings.TrimSpace(ans)); a != "y" && a != "yes" { - fmt.Println("Operation cancelled.") - return nil - } - } - var cl *cf.Client - if t := token(c); t != "" { - cl = cf.New(t) - } else { - fmt.Println("note: no API token, DNS records will be left for you to remove") - } - var zones []cf.Zone - if cl != nil { - zones, _ = cl.Zones() - } - for _, h := range todo { - name := legacyName(h) - fmt.Println("Processing deletion for", h) - exec.Command("cloudflared", "tunnel", "cleanup", name).Run() - if out, err := exec.Command("cloudflared", "tunnel", "delete", "-f", name).CombinedOutput(); err != nil { - fmt.Printf("Failed to delete tunnel for %s, skipping DNS cleanup: %s\n", h, strings.TrimSpace(string(out))) - continue - } - if cl != nil { - if z, err := zoneFor(zones, h); err == nil { - recs, _ := cl.DNSByName(z.ID, h) - for _, r := range recs { - if r.Type == "CNAME" && strings.HasSuffix(r.Content, ".cfargotunnel.com") { - cl.DNSDelete(z.ID, r.ID) - } - } - } - } - fmt.Println("Tunnel for", h, "has been deleted.") - } - return nil -} diff --git a/cmd/cfex/list.go b/cmd/cfex/list.go index bf16f9a..2619f30 100644 --- a/cmd/cfex/list.go +++ b/cmd/cfex/list.go @@ -1,6 +1,10 @@ package main import ( + "path/filepath" + + "gopkg.in/yaml.v3" + "encoding/json" "flag" "fmt" @@ -11,6 +15,7 @@ import ( "sync" "text/tabwriter" + "github.com/muthuishere/cfex-cli/internal/cfd" "github.com/muthuishere/cfex-cli/internal/cfg" "github.com/muthuishere/cfex-cli/internal/disc" "github.com/muthuishere/cfex-cli/internal/ingress" @@ -27,97 +32,170 @@ type Row struct { Account string `json:"account"` } -func cmdList(c *cfg.Config, args []string) error { - fs := flag.NewFlagSet("list", flag.ExitOnError) - asJSON := fs.Bool("json", false, "machine-readable output") - noHTTP := fs.Bool("no-http", false, "skip live HTTP checks") - fs.Parse(args) - cl, err := client(c) - if err != nil { // v1 behaviour: no API token, just what cloudflared knows - fmt.Fprintln(os.Stderr, "note:", err, "\nshowing cloudflared's own tunnel list only (cfex v1 view)") - return legacyList() - } - accts, err := cl.Accounts() - if err != nil { - return err +// tinfo is one tunnel from whichever source is available. +type tinfo struct { + ID, Name, Account string + Running bool + Rules []ingress.Rule +} + +// localRules finds ingress rules for a tunnel id in cfex's own files and in ~/.cloudflared/*.yml. +func localRules(c *cfg.Config, id string) []ingress.Rule { + files, _ := filepath.Glob(filepath.Join(cloudflaredDir(), "*.yml")) + more, _ := filepath.Glob(filepath.Join(cfg.StateDir(), "instant", "*.yml")) + files = append(append(files, more...), cfg.TunnelFile()) + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + continue + } + var doc struct { + Tunnel string `yaml:"tunnel"` + URL string `yaml:"url"` + Ingress []map[string]any `yaml:"ingress"` + } + if yaml.Unmarshal(b, &doc) != nil || doc.Tunnel != id { + continue + } + var rules []ingress.Rule + for _, r := range doc.Ingress { + rules = append(rules, r) + } + if doc.URL != "" { + rules = append(rules, ingress.Rule{"hostname": "(see " + filepath.Base(f) + ")", "service": doc.URL}) + } + return rules } - jobs := disc.Jobs() - byID := map[string]disc.Job{} - for _, j := range jobs { - if j.TunnelID != "" { - byID[j.TunnelID] = j + return nil +} + +func cloudflaredDir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".cloudflared") } + +// allTunnels lists every tunnel the account has: through cloudflared when it is logged in, otherwise through the API +// when a token is available. The API (if present) also supplies remote ingress for remotely-managed tunnels. +func allTunnels(c *cfg.Config) ([]tinfo, error) { + api := apiClient(c) + var out []tinfo + if cfd.HasCert() { + ts, err := cfd.List() + if err != nil { + return nil, err + } + acct := "" + if api != nil { + acct, _ = pickAccount(c, api) } + for _, t := range ts { + ti := tinfo{ID: t.ID, Name: t.Name, Running: t.Running(), Rules: localRules(c, t.ID)} + if len(ti.Rules) == 0 && api != nil && acct != "" { + _, ti.Rules, _ = api.TunnelConfig(acct, t.ID) + } + out = append(out, ti) + } + return out, nil + } + if api == nil { + return nil, fmt.Errorf("cannot list tunnels: cloudflared is not logged in (run `cloudflared tunnel login`) and there is no API token (CLOUDFLARE_API_TOKEN)") + } + accts, err := api.Accounts() + if err != nil { + return nil, err } - var rows []Row for _, a := range accts { - ts, err := cl.Tunnels(a.ID) + ts, err := api.Tunnels(a.ID) if err != nil { fmt.Fprintf(os.Stderr, "cfex: tunnels of %q: %v\n", a.Name, err) continue } - type res struct{ rules []ingress.Rule } - out := make([]res, len(ts)) + res := make([][]ingress.Rule, len(ts)) var wg sync.WaitGroup for i := range ts { wg.Add(1) go func(i int) { defer wg.Done() - _, r, _ := cl.TunnelConfig(a.ID, ts[i].ID) - out[i] = res{r} + _, res[i], _ = api.TunnelConfig(a.ID, ts[i].ID) + if len(res[i]) == 0 { + res[i] = localRules(c, ts[i].ID) + } }(i) } wg.Wait() for i, t := range ts { - class := classify(c, t.Name, t.ID) - run := "down" - if t.Status == "healthy" { - run = "running" - } else if t.Status == "degraded" { - run = "degraded" - } - local := "-" - j, ok := byID[t.ID] - if !ok { // no token-derived match: fall back to launchd label naming - for _, jj := range jobs { - if sn := disc.ShortName(jj.Label); jj.TunnelID == "" && jj.ForwardPort == "" && (sn == t.Name || sn+"-demo" == t.Name || sn == strings.TrimSuffix(t.Name, "-demo")) { - j, ok = jj, true - } + out = append(out, tinfo{ID: t.ID, Name: t.Name, Account: a.Name, Running: t.Status == "healthy", Rules: res[i]}) + } + } + return out, nil +} + +func cmdList(c *cfg.Config, args []string) error { + fs := flag.NewFlagSet("list", flag.ExitOnError) + asJSON := fs.Bool("json", false, "machine-readable output") + noHTTP := fs.Bool("no-http", false, "skip live HTTP checks") + fs.Parse(args) + tunnels, err := allTunnels(c) + if err != nil { + return err + } + jobs := disc.Jobs() + byID := map[string]disc.Job{} + for _, j := range jobs { + if j.TunnelID != "" { + byID[j.TunnelID] = j + } + } + var rows []Row + for _, t := range tunnels { + class := classify(c, t.Name, t.ID) + run := "down" + if t.Running { + run = "running" + } + local := "-" + j, ok := byID[t.ID] + if !ok && t.Name == c.TunnelName { + for _, jj := range jobs { + if jj.Label == c.ServiceLabel { + j, ok = jj, true } } - if ok { - local = j.Label - if !j.Loaded { - local += " (not loaded)" - } - if j.Disabled { - local += " (disabled)" + } + if !ok { // no token-derived match: fall back to launchd label naming + for _, jj := range jobs { + if sn := disc.ShortName(jj.Label); jj.TunnelID == "" && jj.ForwardPort == "" && (sn == t.Name || sn+"-demo" == t.Name || sn == strings.TrimSuffix(t.Name, "-demo")) { + j, ok = jj, true } } - n := 0 - for _, r := range out[i].rules { - h, _ := r["hostname"].(string) - svc, _ := r["service"].(string) - if h == "" && strings.HasPrefix(svc, "http_status") { - continue - } - if h == "" { - h = "(catch-all)" - if class == "legacy" { // a v1 tunnel: its hostname is encoded in the name - h = strings.ReplaceAll(strings.TrimPrefix(t.Name, "tunnel_"), "_", ".") - } - } else if pth, _ := r["path"].(string); pth != "" { - h += pth - } - rows = append(rows, Row{h, portOf(svc), t.Name, class, run, "", local, a.Name}) - n++ + } + if ok { + local = j.Label + if !j.Loaded { + local += " (not loaded)" } - if n == 0 { - h := "(no routes)" - if class == "legacy" { - h = strings.ReplaceAll(strings.TrimPrefix(t.Name, "tunnel_"), "_", ".") - } - rows = append(rows, Row{h, "-", t.Name, class, run, "", local, a.Name}) + if j.Disabled { + local += " (disabled)" + } + } + n := 0 + for _, r := range t.Rules { + h, _ := r["hostname"].(string) + svc, _ := r["service"].(string) + if h == "" && strings.HasPrefix(svc, "http_status") { + continue + } + if h == "" { + h = "(catch-all)" + } else if pth, _ := r["path"].(string); pth != "" { + h += pth + } + rows = append(rows, Row{h, portOf(svc), t.Name, class, run, "", local, t.Account}) + n++ + } + if n == 0 { + h := "(no routes known)" + if class == "instant" { + h = strings.ReplaceAll(strings.TrimPrefix(t.Name, instantPrefix), "-", ".") } + rows = append(rows, Row{h, "-", t.Name, class, run, "", local, t.Account}) } } // managed routes that are stopped have no ingress rule: show them from config @@ -211,23 +289,3 @@ func cmdList(c *cfg.Config, args []string) error { } return nil } - -// legacyList prints cloudflared's own tunnels the way cfex v1 did. -func legacyList() error { - ts, err := localTunnels() - if err != nil { - return err - } - fmt.Println("Active Tunnels:\n----------------------------------------") - if len(ts) == 0 { - fmt.Println("No active tunnels found.") - } - for _, t := range ts { - n := t.Name - if strings.HasPrefix(n, "tunnel_") { - n = strings.ReplaceAll(strings.TrimPrefix(n, "tunnel_"), "_", ".") - } - fmt.Printf("%-40s %s\n", n, t.ID) - } - return nil -} diff --git a/cmd/cfex/main.go b/cmd/cfex/main.go index ac5747e..63fc0ed 100644 --- a/cmd/cfex/main.go +++ b/cmd/cfex/main.go @@ -12,25 +12,52 @@ import ( "github.com/muthuishere/cfex-cli/internal/cfg" ) -const version = "2.0.0" +var version = "2.0.0" // set by goreleaser -const usage = `cfex: expose a local port on your own domain through Cloudflare, and manage every tunnel on this machine. +const usage = `cfex - Cloudflare Exposer CLI: put a local port on your own domain, and manage every tunnel on this machine. - cfex : [--https] [--verify-cert] foreground tunnel, Ctrl+C stops it (cfex v1 behaviour, unchanged) - cfex same, original syntax - cfex add [--protected] durable route on cfex's own tunnel, kept running by launchd/systemd - cfex list [--json] [--no-http] EVERYTHING on this machine: managed, protected, client, legacy, unmanaged - cfex stop stop serving (keeps config + DNS) - cfex start serve again - cfex delete ... [--yes] durable route: dry run unless --yes; v1 tunnel: asks to confirm - cfex status cfex's own tunnel at a glance - cfex doctor cloudflared, token, service, DNS <-> ingress, plaintext token files - cfex import [--dry-run] adopt an existing tunnel into config WITHOUT changing what it serves - cfex -h | -v +Usage: + cfex : Instant: https:// -> localhost:, Ctrl+C stops and cleans up + cfex Same, two-argument syntax + cfex : --https Force HTTPS to the local service (auto-detected when omitted) + cfex : --https --verify-cert HTTPS with certificate verification + cfex : --keep Make it permanent (same as: cfex add ) + cfex add [--https] [--verify-cert] [--protected] + Durable route on cfex's own tunnel (launchd / systemd keeps it running) + cfex list [--json] [--no-http] Everything on this machine, with live HTTP status + cfex stop|start Stop / resume serving (keeps config and DNS) + cfex delete ... [--yes] Delete tunnels (asks y/N on a terminal; --yes for scripts) + cfex status | doctor | troubleshoot Health, prerequisites and what needs an API token, common problems + cfex import [--dry-run] Adopt an existing tunnel into config without changing what it serves + cfex skill install|uninstall|path Agent skill (~/.claude/skills/cfex, ~/.agents/skills/cfex) + cfex uninstall Remove cfex (never your tunnels unless you ask) -Token: CLOUDFLARE_API_TOKEN (CLOUDFLARE_API_KEY still accepted), or 'token_cmd' in the config. +Options: + --https Force HTTPS mode for the local service + --verify-cert Verify the local service's SSL certificate (default: ignore certificate errors) + --keep Keep the tunnel instead of removing it on exit + -y, --yes Do not ask for confirmation + -h, --help Show this help + -v, --version Show version + +Examples: + cfex example.com:8080 Auto-detect whether port 8080 speaks HTTP or HTTPS + cfex sub.example.com:3000 --https --verify-cert + cfex api.example.com 8080 + cfex list + cfex delete example.com + +Note: certificate errors from a local HTTPS service are ignored by default for easier development; use --verify-cert for strict checking. +Setup: run 'cloudflared tunnel login' once. An API token (CLOUDFLARE_API_TOKEN) is optional; 'cfex doctor' says what it enables. Config: ~/.config/cfex/config.yaml · state: ~/.local/share/cfex/` +const troubleshoot = `Troubleshooting +1. Installation: verify cloudflared is installed (cfex doctor). Check permissions on the install directory (/usr/local/bin or ~/.local/bin). +2. Authentication: run 'cloudflared tunnel login' again. If you use an API token, make sure it is exported and valid (cfex doctor). +3. DNS: allow time for propagation. Verify the domain is in your Cloudflare account and that the hostname has no existing record (cfex never overwrites one). +4. Connection: make sure the service is running on the port you gave (cfex warns when nothing listens) and check port conflicts or firewalls. +5. Windows: use WSL (wsl --install in an elevated PowerShell, restart, then follow the Linux steps).` + func fatal(msg string) { fmt.Fprintln(os.Stderr, "cfex:", msg); os.Exit(1) } // withToken re-executes cfex under the configured token_cmd (e.g. "op run --") when no token is in the environment, @@ -61,6 +88,7 @@ func token(c *cfg.Config) string { } var subcommands = map[string]func(*cfg.Config, []string) error{ + "skill": cmdSkill, "uninstall": cmdUninstall, "troubleshoot": func(*cfg.Config, []string) error { fmt.Println(troubleshoot); return nil }, "add": cmdAdd, "list": cmdList, "ls": cmdList, "stop": cmdStop, "start": cmdStart, "delete": cmdDelete, "rm": cmdDelete, "status": cmdStatus, "doctor": cmdDoctor, "import": cmdImport, } @@ -74,6 +102,13 @@ func main() { fmt.Println("cfex version", version) return } + if os.Args[1] == "skill" || os.Args[1] == "troubleshoot" { + c, _ := cfg.Load() + if err := subcommands[os.Args[1]](c, os.Args[2:]); err != nil { + fatal(err.Error()) + } + return + } c, err := cfg.Load() if err != nil { fatal(err.Error()) @@ -86,9 +121,9 @@ func main() { } return } - // cfex v1 syntax: : or , foreground. + // instant mode: : or withToken(c) - if err := legacyRun(c, os.Args[1:]); err != nil { + if err := cmdInstant(c, os.Args[1:]); err != nil { fatal(err.Error()) } } diff --git a/cmd/cfex/main_test.go b/cmd/cfex/main_test.go index 1ed7ead..38f3204 100644 --- a/cmd/cfex/main_test.go +++ b/cmd/cfex/main_test.go @@ -1,11 +1,15 @@ package main import ( + "bytes" + "context" + "errors" "fmt" "os" "strings" "testing" + "github.com/muthuishere/cfex-cli/internal/cfd" "github.com/muthuishere/cfex-cli/internal/cfg" ) @@ -19,47 +23,130 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func TestLegacyParse(t *testing.T) { +func TestInstantParse(t *testing.T) { for _, tc := range []struct { args []string host string port int https bool + keep bool ok bool }{ - {[]string{"dev.example.com:3000"}, "dev.example.com", 3000, false, true}, - {[]string{"dev.example.com", "8080"}, "dev.example.com", 8080, false, true}, - {[]string{"dev.example.com:8443", "--https", "--verify-cert"}, "dev.example.com", 8443, true, true}, - {[]string{"--https", "Dev.Example.com:1"}, "dev.example.com", 1, true, true}, - {[]string{"dev.example.com:0"}, "", 0, false, false}, - {[]string{"dev.example.com:70000"}, "", 0, false, false}, - {[]string{"justahost"}, "", 0, false, false}, - {[]string{"a.b", "x"}, "", 0, false, false}, - {[]string{"--bogus", "a.b:1"}, "", 0, false, false}, + {[]string{"dev.example.com:3000"}, "dev.example.com", 3000, false, false, true}, + {[]string{"dev.example.com", "8080"}, "dev.example.com", 8080, false, false, true}, + {[]string{"dev.example.com:8443", "--https", "--verify-cert"}, "dev.example.com", 8443, true, false, true}, + {[]string{"--https", "Dev.Example.com:1"}, "dev.example.com", 1, true, false, true}, + {[]string{"dev.example.com:3000", "--keep"}, "dev.example.com", 3000, false, true, true}, + {[]string{"dev.example.com:0"}, "", 0, false, false, false}, + {[]string{"dev.example.com:70000"}, "", 0, false, false, false}, + {[]string{"justahost"}, "", 0, false, false, false}, + {[]string{"a.b", "x"}, "", 0, false, false, false}, + {[]string{"--bogus", "a.b:1"}, "", 0, false, false, false}, } { - a, err := legacyParse(tc.args) + a, err := instantParse(tc.args) if (err == nil) != tc.ok { t.Fatalf("%v: err=%v", tc.args, err) } - if tc.ok && (a.Host != tc.host || a.Port != tc.port || a.HTTPS != tc.https) { + if tc.ok && (a.Host != tc.host || a.Port != tc.port || a.HTTPS != tc.https || a.Keep != tc.keep) { t.Fatalf("%v: got %+v", tc.args, a) } } } -func TestLegacyConfigMatchesV1(t *testing.T) { - http := legacyConfig(false, 3000, "ID", true) - if !strings.Contains(http, "url: http://localhost:3000\ntunnel: ID\n") || strings.Contains(http, "originRequest") { +func TestInstantConfig(t *testing.T) { + http := instantConfig("d.example.com", 3000, "ID", false, false) + if !strings.Contains(http, "hostname: d.example.com") || !strings.Contains(http, "service: http://127.0.0.1:3000") || strings.Contains(http, "originRequest") || !strings.HasSuffix(http, "service: http_status:404\n") { t.Fatal(http) } - if s := legacyConfig(true, 8443, "ID", true); !strings.Contains(s, "url: https://localhost:8443") || !strings.Contains(s, "noTLSVerify: true") || !strings.Contains(s, "disableChunkedEncoding: true") { + if s := instantConfig("d.example.com", 8443, "ID", true, false); !strings.Contains(s, "https://127.0.0.1:8443") || !strings.Contains(s, "noTLSVerify: true") || !strings.Contains(s, "disableChunkedEncoding: true") { t.Fatal(s) } - if s := legacyConfig(true, 8443, "ID", false); strings.Contains(s, "noTLSVerify") { + if s := instantConfig("d.example.com", 8443, "ID", true, true); strings.Contains(s, "noTLSVerify") { t.Fatal("--verify-cert must not disable verification") } - if legacyName("dev.example.com") != "tunnel_dev_example_com" { - t.Fatal("v1 tunnel naming must be unchanged") + if instantName("dev.example.com") != "cfex-instant-dev-example-com" { + t.Fatal("instant tunnel naming") + } +} + +// fakeInstant records what an instant session does so we can prove the cleanup. +type fakeInstant struct { + existing *cfd.Tunnel + routeErr error + calls []string + cancel context.CancelFunc + cancelOnRun bool + runErr error + dnsDone bool +} + +func (f *fakeInstant) Find(string) (*cfd.Tunnel, error) { return f.existing, nil } +func (f *fakeInstant) Create(n string) (string, error) { + f.calls = append(f.calls, "create "+n) + return "TID", nil +} +func (f *fakeInstant) RouteDNS(n, h string) error { + f.calls = append(f.calls, "route "+h) + return f.routeErr +} +func (f *fakeInstant) WriteConfig(id string) (string, error) { return "/tmp/x.yml", nil } +func (f *fakeInstant) Run(ctx context.Context, c, n string) error { + f.calls = append(f.calls, "run") + if f.cancelOnRun { + f.cancel() // the user presses Ctrl+C + } + <-ctx.Done() + return f.runErr +} +func (f *fakeInstant) DeleteDNS(h, id string) (bool, error) { + f.calls = append(f.calls, "delete-dns "+h+" "+id) + return f.dnsDone, nil +} +func (f *fakeInstant) DeleteTunnel(n string) error { + f.calls = append(f.calls, "delete-tunnel "+n) + return nil +} + +func TestInstantCtrlCRemovesDNSRecordAndTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + f := &fakeInstant{cancel: cancel, cancelOnRun: true, dnsDone: true} + var out bytes.Buffer + if err := instantSession(ctx, f, "dev.example.com", &out); err != nil { + t.Fatal(err) + } + want := []string{"create cfex-instant-dev-example-com", "route dev.example.com", "run", "delete-dns dev.example.com TID", "delete-tunnel cfex-instant-dev-example-com"} + if strings.Join(f.calls, "|") != strings.Join(want, "|") { + t.Fatalf("calls:\n got %v\nwant %v", f.calls, want) + } + if !strings.Contains(out.String(), "DNS record for dev.example.com removed") { + t.Fatal(out.String()) + } +} + +func TestInstantFailedRouteStillRemovesTheTunnelItCreated(t *testing.T) { + f := &fakeInstant{routeErr: errors.New("record exists")} + err := instantSession(context.Background(), f, "dev.example.com", &bytes.Buffer{}) + if err == nil { + t.Fatal("route failure on a fresh tunnel must be an error") + } + last := f.calls[len(f.calls)-1] + if last != "delete-tunnel cfex-instant-dev-example-com" { + t.Fatalf("tunnel not cleaned up: %v", f.calls) + } + for _, c := range f.calls { + if strings.HasPrefix(c, "run") { + t.Fatal("must not run after a failed route") + } + } +} + +func TestInstantWithoutTokenSaysDNSIsLeft(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + f := &fakeInstant{cancel: cancel, cancelOnRun: true, dnsDone: false} + var out bytes.Buffer + instantSession(ctx, f, "dev.example.com", &out) + if !strings.Contains(out.String(), "delete the CNAME dev.example.com yourself") { + t.Fatal(out.String()) } } @@ -98,9 +185,21 @@ func TestTokenFromEnvOnly(t *testing.T) { func TestServiceUnitNeverContainsToken(t *testing.T) { c := cfg.Default() - body := serviceBody(c, []string{"/usr/bin/cloudflared", "tunnel", "run", "--token-file", "/x/connector.token"}) - if strings.Contains(strings.ToLower(body), "eyj") || !strings.Contains(body, "connector.token") { - t.Fatal("unit must reference the token FILE, never embed a token") + body := serviceBody(c, []string{"/usr/bin/cloudflared", "tunnel", "--config", "/x/tunnel.yml", "run", "cfex"}) + if strings.Contains(strings.ToLower(body), "token") || !strings.Contains(body, "tunnel.yml") { + t.Fatal("unit must run from the config + cloudflared credentials file, never a token") + } +} + +func TestDeleteNeedsConfirmationOnTTYAndDryRunsWithoutOne(t *testing.T) { + // no terminal and no --yes: nothing may be applied + isTTY = func() bool { return false } + defer func() { isTTY = func() bool { return false } }() + c, f := setup(t) + var out bytes.Buffer + apply := false // what cmdDelete computes for yes=false, no TTY + if err := deleteRoute(f, c, "t.example.com", apply, &out); err != nil || f.saved != 0 || len(f.deleted) != 0 { + t.Fatal("no TTY and no --yes must be a dry run") } } diff --git a/cmd/cfex/misc_test.go b/cmd/cfex/misc_test.go new file mode 100644 index 0000000..ef8055f --- /dev/null +++ b/cmd/cfex/misc_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "errors" + cfexcli "github.com/muthuishere/cfex-cli" + "github.com/muthuishere/cfex-cli/internal/cfd" + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +func fakeHome(t *testing.T) string { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("CFEX_CONFIG", filepath.Join(h, "cfg", "config.yaml")) + return h +} + +func TestSkillInstallAndUninstall(t *testing.T) { + h := fakeHome(t) + if err := cmdSkill(cfg.Default(), []string{"install"}); err != nil { + t.Fatal(err) + } + for _, d := range []string{".claude/skills/cfex", ".agents/skills/cfex"} { + b, err := os.ReadFile(filepath.Join(h, d, "SKILL.md")) + if err != nil || string(b) != string(cfexcli.Skill) { + t.Fatalf("%s: %v", d, err) + } + } + if !strings.Contains(string(cfexcli.Skill), "cfex dev.yourdomain.com:3000") || !strings.HasPrefix(string(cfexcli.Skill), "---\nname: cfex\n") { + t.Fatal("skill must lead with the instant examples and a trigger line") + } + if err := cmdSkill(cfg.Default(), []string{"uninstall"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(h, ".claude/skills/cfex")); err == nil { + t.Fatal("skill not removed") + } +} + +func TestUninstallRemovesBinaryAndSkillButNotCloudflaredFiles(t *testing.T) { + h := fakeHome(t) + isTTY = func() bool { return false } // no prompts: everything optional stays + bin := filepath.Join(h, "cfex") + os.WriteFile(bin, []byte("x"), 0o755) + selfPath = func() (string, error) { return bin, nil } + cmdSkill(cfg.Default(), []string{"install"}) + os.MkdirAll(filepath.Join(h, ".cloudflared"), 0o700) + os.WriteFile(filepath.Join(h, ".cloudflared", "cert.pem"), []byte("c"), 0o600) + c := cfg.Default() + c.Save() + if err := cmdUninstall(c, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(bin); err == nil { + t.Fatal("binary not removed") + } + if _, err := os.Stat(filepath.Join(h, ".cloudflared", "cert.pem")); err != nil { + t.Fatal("cloudflared login must survive uninstall unless asked") + } + if _, err := os.Stat(cfg.Path()); err != nil { + t.Fatal("config must survive uninstall unless asked") + } + if err := cmdUninstall(c, []string{"--purge-config", "--purge-cloudflared"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(h, ".cloudflared")); err == nil { + t.Fatal("--purge-cloudflared must remove it") + } +} + +func TestTunnelFileRoundTripKeepsOtherRoutes(t *testing.T) { + fakeHome(t) + rules, _ := ingress.Add(nil, "a.example.com", "http://127.0.0.1:1") + if err := saveRules("TID", rules); err != nil { + t.Fatal(err) + } + loaded, err := loadRules() + if err != nil { + t.Fatal(err) + } + next, _ := ingress.Add(loaded, "b.example.com", "http://127.0.0.1:2") + saveRules("TID", next) + again, _ := loadRules() + if s, _ := ingress.Service(again, "a.example.com"); s != "http://127.0.0.1:1" || !ingress.Has(again, "b.example.com") || !ingress.IsCatchAll(again[len(again)-1]) { + t.Fatalf("%v", again) + } + if r := routeRule("h.example.com", "https://127.0.0.1:1", true, false); r["originRequest"] == nil { + t.Fatal("https origins skip verification unless --verify-cert") + } + if r := routeRule("h.example.com", "https://127.0.0.1:1", true, true); r["originRequest"] != nil { + t.Fatal("--verify-cert keeps verification on") + } +} + +func TestConfirmDeleteDefaultsToNo(t *testing.T) { + for in, want := range map[string]bool{"y\n": true, "Y\n": true, "yes\n": true, "\n": false, "n\n": false, "": false, "maybe\n": false} { + var out bytes.Buffer + if got := confirmDelete(strings.NewReader(in), &out, 2); got != want { + t.Fatalf("%q -> %v", in, got) + } + if !strings.Contains(out.String(), "delete these 2 tunnels? (y/N)") { + t.Fatal(out.String()) + } + } +} + +func TestDetectHTTPS(t *testing.T) { + tls := httptest.NewTLSServer(http.NewServeMux()) + defer tls.Close() + plain := httptest.NewServer(http.NewServeMux()) + defer plain.Close() + port := func(s *httptest.Server) int { u, _ := url.Parse(s.URL); p, _ := strconv.Atoi(u.Port()); return p } + if !detectHTTPS(port(tls)) || detectHTTPS(port(plain)) { + t.Fatal("must detect a TLS service and not a plain one") + } +} + +func TestWaitUntilTimesOutAndConfirms(t *testing.T) { + if !waitUntil(time.Second, time.Millisecond, "x", func() bool { return true }) { + t.Fatal("immediate success") + } + if waitUntil(30*time.Millisecond, 5*time.Millisecond, "x", func() bool { return false }) { + t.Fatal("must time out") + } +} + +func TestInstantReusesALeftoverTunnel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + f := &fakeInstant{existing: &cfd.Tunnel{ID: "OLD", Name: "cfex-instant-dev-example-com"}, cancel: cancel, cancelOnRun: true, routeErr: errors.New("already routed")} + var out bytes.Buffer + if err := instantSession(ctx, f, "dev.example.com", &out); err != nil { + t.Fatal(err) + } + for _, c := range f.calls { + if strings.HasPrefix(c, "create") { + t.Fatal("must reuse the leftover tunnel, not create another") + } + } + if !strings.Contains(out.String(), "Found existing tunnel") || !strings.Contains(out.String(), "Warning: DNS route creation failed") { + t.Fatal(out.String()) + } +} + +func TestUsageCoversEveryCommand(t *testing.T) { + for name := range subcommands { + if name == "ls" || name == "rm" { + continue + } + if !strings.Contains(usage, "cfex "+name) && !strings.Contains(usage, name) { + t.Fatalf("usage does not mention %s", name) + } + } + for _, must := range []string{"--https", "--verify-cert", "--keep", "-v, --version", "Examples:", "cloudflared tunnel login"} { + if !strings.Contains(usage, must) { + t.Fatalf("usage lacks %q", must) + } + } + if !strings.Contains(troubleshoot, "WSL") { + t.Fatal("troubleshooting keeps the WSL note") + } +} diff --git a/cmd/cfex/service.go b/cmd/cfex/service.go index e1857e5..b8839aa 100644 --- a/cmd/cfex/service.go +++ b/cmd/cfex/service.go @@ -10,69 +10,19 @@ import ( "strconv" "strings" - "github.com/muthuishere/cfex-cli/internal/cf" + "github.com/muthuishere/cfex-cli/internal/cfd" "github.com/muthuishere/cfex-cli/internal/cfg" ) -// The durable connector for cfex's own tunnel runs as a launchd agent (macOS) or a systemd user unit (Linux). -// The connector token is never written into the unit: it is read from a 0600 file (default) or handed over -// by the configured secret manager at start time. - -func tokenFilePath() string { return filepath.Join(cfg.StateDir(), "connector.token") } +// The durable tunnel runs as a launchd agent (macOS) or a systemd user unit (Linux): `cloudflared tunnel --config +// ~/.config/cfex/tunnel.yml run `. It uses cloudflared's own credentials file, so no token is ever written into the unit. func connectorArgs(c *cfg.Config) ([]string, error) { - cfd, err := exec.LookPath("cloudflared") - if err != nil { - return nil, fmt.Errorf("cloudflared not installed (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)") - } - if strings.TrimSpace(c.ConnectorRunCmd) == "" { - return []string{cfd, "tunnel", "--no-autoupdate", "run", "--token-file", tokenFilePath()}, nil - } - if c.ConnectorEnv == "" { - return nil, fmt.Errorf("connector_run_cmd needs connector_env (the variable it exports)") - } - pre := strings.Fields(c.ConnectorRunCmd) - bin, err := exec.LookPath(pre[0]) + bin, err := cfd.Bin() if err != nil { return nil, err } - inner := fmt.Sprintf(`TUNNEL_TOKEN="$%s" exec %s tunnel --no-autoupdate run`, c.ConnectorEnv, cfd) - return append(append([]string{bin}, pre[1:]...), "/bin/sh", "-c", inner), nil -} - -// storeToken keeps the connector token where the service can read it, never printing it. -func storeToken(c *cfg.Config, cl *cf.Client, fresh bool) error { - if strings.TrimSpace(c.ConnectorRunCmd) == "" { - if _, err := os.Stat(tokenFilePath()); err == nil && !fresh { - return nil - } - tok, err := cl.TunnelToken(c.AccountID, c.TunnelID) - if err != nil { - return err - } - if err := os.MkdirAll(cfg.StateDir(), 0o700); err != nil { - return err - } - return os.WriteFile(tokenFilePath(), []byte(tok), 0o600) - } - if !fresh { - return nil - } - if strings.TrimSpace(c.ConnectorSetCmd) == "" { - return fmt.Errorf("connector_run_cmd is set but connector_set_cmd is not: cannot store the new connector token") - } - tok, err := cl.TunnelToken(c.AccountID, c.TunnelID) - if err != nil { - return err - } - f := strings.Fields(c.ConnectorSetCmd) - cmd := exec.Command(f[0], f[1:]...) - cmd.Stdin = strings.NewReader(tok) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("connector_set_cmd failed: %v: %s", err, strings.ReplaceAll(string(out), tok, "[redacted]")) - } - fmt.Println("connector token stored via connector_set_cmd") - return nil + return []string{bin, "tunnel", "--no-autoupdate", "--config", cfg.TunnelFile(), "run", c.TunnelName}, nil } func plistPath(c *cfg.Config) string { @@ -112,7 +62,7 @@ func serviceBody(c *cfg.Config, args []string) string { for i, x := range args { q[i] = "'" + strings.ReplaceAll(x, "'", `'\''`) + "'" } - return fmt.Sprintf("[Unit]\nDescription=cfex tunnel connector\nAfter=network-online.target\n\n[Service]\nExecStart=%s\nRestart=always\nRestartSec=5\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=default.target\n", + return fmt.Sprintf("[Unit]\nDescription=cfex tunnel\nAfter=network-online.target\n\n[Service]\nExecStart=%s\nRestart=always\nRestartSec=5\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=default.target\n", strings.Join(q, " "), logp, logp) } @@ -145,6 +95,18 @@ func serviceStop(c *cfg.Config) error { return exec.Command("systemctl", "--user", "stop", c.ServiceLabel+".service").Run() } +// serviceReload makes cloudflared pick up a changed tunnel.yml (it does not hot-reload local config): a restart, +// so every route on the durable tunnel reconnects for a second or two. +func serviceReload(c *cfg.Config) error { + if !serviceLoaded(c) { + return installService(c) + } + if runtime.GOOS == "darwin" { + return exec.Command("launchctl", "kickstart", "-k", domain()+"/"+c.ServiceLabel).Run() + } + return exec.Command("systemctl", "--user", "restart", c.ServiceLabel+".service").Run() +} + // installService writes the unit (idempotent) and starts it. func installService(c *cfg.Config) error { args, err := connectorArgs(c) diff --git a/cmd/cfex/skill.go b/cmd/cfex/skill.go new file mode 100644 index 0000000..6783844 --- /dev/null +++ b/cmd/cfex/skill.go @@ -0,0 +1,117 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + cfexcli "github.com/muthuishere/cfex-cli" + "github.com/muthuishere/cfex-cli/internal/cfg" +) + +// selfPath is the running binary (a variable so tests never delete the test binary). +var selfPath = os.Executable + +func skillDirs() []string { + h, _ := os.UserHomeDir() + return []string{filepath.Join(h, ".claude", "skills", "cfex"), filepath.Join(h, ".agents", "skills", "cfex")} +} + +// cmdSkill: `cfex skill install|uninstall|path`. +func cmdSkill(c *cfg.Config, args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: cfex skill install | uninstall | path") + } + switch args[0] { + case "install": + for _, d := range skillDirs() { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(d, "SKILL.md"), cfexcli.Skill, 0o644); err != nil { + return err + } + fmt.Println("installed", filepath.Join(d, "SKILL.md")) + } + case "uninstall": + for _, d := range skillDirs() { + if err := os.RemoveAll(d); err != nil { + return err + } + fmt.Println("removed", d) + } + case "path": + for _, d := range skillDirs() { + fmt.Println(d) + } + default: + return fmt.Errorf("usage: cfex skill install | uninstall | path") + } + return nil +} + +func ask(q string) bool { + if !isTTY() { + return false + } + fmt.Print(q + " (y/N) ") + a, _ := bufio.NewReader(os.Stdin).ReadString('\n') + a = strings.ToLower(strings.TrimSpace(a)) + return a == "y" || a == "yes" +} + +// cmdUninstall removes the cfex binary and skill. Everything else is opt-in (a prompt on a terminal, or a flag), and +// tunnels and DNS records in your Cloudflare account are never deleted. +func cmdUninstall(c *cfg.Config, args []string) error { + var stopSvc, purgeCfg, purgeCFD bool + for _, a := range args { + switch a { + case "--stop-service": + stopSvc = true + case "--purge-config": + purgeCfg = true + case "--purge-cloudflared": + purgeCFD = true + case "--yes", "-y": + default: + return fmt.Errorf("usage: cfex uninstall [--stop-service] [--purge-config] [--purge-cloudflared]") + } + } + for _, d := range skillDirs() { + if _, err := os.Stat(d); err == nil { + os.RemoveAll(d) + fmt.Println("removed skill", d) + } + } + if c.TunnelID != "" && (stopSvc || ask("Stop and remove the cfex service? Your durable routes will stop (the tunnel and DNS records are not deleted)")) { + serviceStop(c) + os.Remove(plistPath(c)) + os.Remove(unitPath(c)) + fmt.Println("removed service", c.ServiceLabel) + } + if purgeCfg || ask("Remove cfex config and state ("+cfg.Dir()+", "+cfg.StateDir()+")?") { + os.RemoveAll(cfg.Dir()) + os.RemoveAll(cfg.StateDir()) + fmt.Println("removed cfex config and state") + } + if purgeCFD || ask("Remove cloudflared configuration files (~/.cloudflared)? This deletes your cloudflared login and every tunnel credentials file") { + os.RemoveAll(cloudflaredDir()) + fmt.Println("removed", cloudflaredDir()) + } + self, err := selfPath() + if err == nil { + if rerr := os.Remove(self); rerr != nil { + fmt.Printf("could not remove %s (%v); run: sudo rm %s\n", self, rerr, self) + if p, e := exec.LookPath("cfex"); e == nil && p != self { + fmt.Println("another cfex is also on PATH:", p) + } + } else { + fmt.Println("removed", self) + } + } + fmt.Println("Uninstallation completed. Tunnels and DNS records in your Cloudflare account were left untouched (use `cfex delete` first if you want them gone).") + return nil +} diff --git a/cmd/cfex/stopstart.go b/cmd/cfex/stopstart.go index 4ea7c0e..034014b 100644 --- a/cmd/cfex/stopstart.go +++ b/cmd/cfex/stopstart.go @@ -36,21 +36,22 @@ func stopStart(c *cfg.Config, args []string, start bool) error { if r.Protected && !forced() { return fmt.Errorf("%s is a protected route: %s needs an explicit decision (set CFEX_FORCE=1)", host, verb) } - cl, err := client(c) - if err != nil { - return err - } - acct, err := pickAccount(c, cl) - if err != nil { - return err - } - full, rules, err := cl.TunnelConfig(acct, r.TunnelID) + rules, err := loadRules() if err != nil { return err } var next []ingress.Rule if start { - next, err = ingress.Add(rules, host, fmt.Sprintf("http://127.0.0.1:%d", r.Port)) + next, err = ingress.Add(rules, host, serviceURL(r.HTTPS, r.Port)) + if err == nil { + for _, x := range next { + if h, _ := x["hostname"].(string); h == host { + for k, v := range routeRule(host, serviceURL(r.HTTPS, r.Port), r.HTTPS, r.VerifyTLS) { + x[k] = v + } + } + } + } if err == ingress.ErrExists { r.State = "running" return c.Save() @@ -65,12 +66,18 @@ func stopStart(c *cfg.Config, args []string, start bool) error { if err != nil { return err } - if err := cl.PutIngress(acct, r.TunnelID, full, next); err != nil { + if err := saveRules(c.TunnelID, next); err != nil { return err } r.State = map[bool]string{true: "running", false: "stopped"}[start] + if err := c.Save(); err != nil { + return err + } + if err := serviceReload(c); err != nil { + return err + } fmt.Printf("%s: %s\n", host, r.State) - return c.Save() + return nil } for _, a := range c.Adopted { if a.Tunnel == target || a.Label == target { diff --git a/cmd/cfex/tunnelfile.go b/cmd/cfex/tunnelfile.go new file mode 100644 index 0000000..8c9e0d3 --- /dev/null +++ b/cmd/cfex/tunnelfile.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/muthuishere/cfex-cli/internal/cfd" + "github.com/muthuishere/cfex-cli/internal/cfg" + "github.com/muthuishere/cfex-cli/internal/ingress" +) + +// cfex's durable tunnel is a locally-managed cloudflared tunnel whose ingress lives in ~/.config/cfex/tunnel.yml. +// Routes are added/removed with the pure merge in internal/ingress, so one route never alters another. + +func loadRules() ([]ingress.Rule, error) { + b, err := os.ReadFile(cfg.TunnelFile()) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var doc struct { + Ingress []map[string]any `yaml:"ingress"` + } + if err := yaml.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("%s: %w", cfg.TunnelFile(), err) + } + rules := make([]ingress.Rule, 0, len(doc.Ingress)) + for _, r := range doc.Ingress { + rules = append(rules, r) + } + return rules, nil +} + +func saveRules(id string, rules []ingress.Rule) error { + if err := cfg.GuardTestPath(cfg.TunnelFile()); err != nil { + return err + } + doc := map[string]any{"tunnel": id, "credentials-file": cfd.CredentialsFile(id), "ingress": ingress.Normalize(rules)} + b, err := yaml.Marshal(doc) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(cfg.TunnelFile()), 0o700); err != nil { + return err + } + return os.WriteFile(cfg.TunnelFile(), b, 0o600) +} + +// serviceURL is the origin cloudflared dials for a route. +func serviceURL(https bool, port int) string { + if https { + return fmt.Sprintf("https://127.0.0.1:%d", port) + } + return fmt.Sprintf("http://127.0.0.1:%d", port) +} + +// routeRule builds the ingress rule for a route; https origins skip certificate verification unless verify is set. +func routeRule(host, service string, https, verify bool) ingress.Rule { + r := ingress.Rule{"hostname": host, "service": service} + if https && !verify { + r["originRequest"] = map[string]any{"noTLSVerify": true} + } + return r +} diff --git a/config.example.yaml b/config.example.yaml index e15dd6c..78d8568 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -4,25 +4,20 @@ default_zone: example.com # Needed only if your token can see several Cloudflare accounts. -# account_id: 0123456789abcdef0123456789abcdef +# account_id: 0123456789abcdef0123456789abcdef # only for the API features, when the token sees several accounts # The one tunnel cfex owns for durable routes (`cfex add`), and the launchd label / systemd unit that runs it. tunnel_name: cfex service_label: dev.cfex.tunnel -# Where the API token comes from. By default the environment variable CLOUDFLARE_API_TOKEN +# The Cloudflare API token is OPTIONAL (cfex drives cloudflared). It adds DNS ownership checks, DNS deletion and remote ingress +# in `list`. By default the environment variable CLOUDFLARE_API_TOKEN # (CLOUDFLARE_API_KEY, the cfex v1 name, also works). # token_env: CLOUDFLARE_API_TOKEN # Or run cfex under a secret manager so the token only exists in cfex's own environment: # token_cmd: "op run --" # 1Password; the command must export token_env # token_cmd: "sec run MY_CF_TOKEN --" # token_env: MY_CF_TOKEN -# The durable tunnel's connector token is kept in a 0600 file under ~/.local/share/cfex by default. -# To keep it in a secret manager instead: -# connector_run_cmd: "sec run MY_TUNNEL_TOKEN --" -# connector_env: MY_TUNNEL_TOKEN -# connector_set_cmd: "sec set MY_TUNNEL_TOKEN" # receives the token on stdin - # Tunnel-name globs cfex lists but NEVER modifies (stop/start/delete are refused). protected: [] # e.g. ["billing", "prod-*"] client: [] # e.g. ["acme-demo"]: shown as class "client" diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..d2c0812 --- /dev/null +++ b/embed.go @@ -0,0 +1,9 @@ +// Package cfexcli carries files that ship inside the cfex binary. +package cfexcli + +import _ "embed" + +// Skill is the agent skill installed by `cfex skill install`. +// +//go:embed skills/cfex/SKILL.md +var Skill []byte diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 0e0c0d5..c0f2103 --- a/install.sh +++ b/install.sh @@ -1,35 +1,57 @@ #!/bin/bash +# Installs cfex (v2) from the latest GitHub release. +# curl -sSL https://github.com/muthuishere/cfex-cli/releases/latest/download/install.sh | bash +# Environment: CFEX_VERSION (e.g. v2.0.0), CFEX_INSTALL_DIR, CFEX_NO_SKILL=1 to skip the agent skill. +set -euo pipefail -# Determine OS -OS="$(uname)" -SCRIPT_URL="https://raw.githubusercontent.com/muthuishere/cfex-cli/main/bin/cfex" -INSTALL_DIR="/usr/local/bin" +REPO="muthuishere/cfex-cli" -# Check for sudo privileges -if [ "$EUID" -ne 0 ]; then - if command -v sudo > /dev/null; then - SUDO="sudo" - else - echo "Error: This script requires root privileges. Please run with sudo or as root." - exit 1 - fi -fi - -# Check dependencies check_dependency() { if ! command -v "$1" > /dev/null; then - echo "Error: $1 is not installed. Please install it first." + echo "Error: $1 is not installed. Please install it first." >&2 exit 1 fi } +check_dependency curl +check_dependency tar +check_dependency cloudflared + +case "$(uname -s)" in + Darwin) OS=darwin ;; + Linux) OS=linux ;; + *) echo "Error: unsupported OS $(uname -s). On Windows use WSL." >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + arm64|aarch64) ARCH=arm64 ;; + *) echo "Error: unsupported CPU $(uname -m)" >&2; exit 1 ;; +esac + +VERSION="${CFEX_VERSION:-}" +if [ -z "$VERSION" ]; then + VERSION="$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')" +fi +[ -n "$VERSION" ] || { echo "Error: could not find the latest cfex release" >&2; exit 1; } +NUM="${VERSION#v}" +ASSET="cfex_${NUM}_${OS}_${ARCH}.tar.gz" +BASE="https://github.com/$REPO/releases/download/$VERSION" -check_dependency "curl" -check_dependency "jq" -check_dependency "cloudflared" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +echo "Downloading cfex $VERSION ($OS/$ARCH)..." +curl -sSL -f "$BASE/$ASSET" -o "$TMP/$ASSET" +curl -sSL -f "$BASE/checksums.txt" -o "$TMP/checksums.txt" +( cd "$TMP" && grep " $ASSET\$" checksums.txt > check.txt \ + && { command -v sha256sum >/dev/null && sha256sum -c check.txt >/dev/null || shasum -a 256 -c check.txt >/dev/null; } ) \ + || { echo "Error: checksum verification failed" >&2; exit 1; } +tar -xzf "$TMP/$ASSET" -C "$TMP" cfex -# Download and install the script -echo "Downloading cfex script..." -$SUDO curl -sSL "$SCRIPT_URL" -o "$INSTALL_DIR/cfex" -$SUDO chmod +x "$INSTALL_DIR/cfex" +INSTALL_DIR="${CFEX_INSTALL_DIR:-/usr/local/bin}" +if [ -w "$INSTALL_DIR" ] || [ "$(id -u)" -eq 0 ]; then SUDO="" +elif [ -z "${CFEX_INSTALL_DIR:-}" ] && command -v sudo > /dev/null; then SUDO="sudo" +else INSTALL_DIR="$HOME/.local/bin"; SUDO=""; mkdir -p "$INSTALL_DIR"; fi +$SUDO install -m 0755 "$TMP/cfex" "$INSTALL_DIR/cfex" +echo "Installed $INSTALL_DIR/cfex" +case ":$PATH:" in *":$INSTALL_DIR:"*) ;; *) echo "Note: add $INSTALL_DIR to your PATH" ;; esac -echo "Installation completed! You can now use 'cfex' command." +if [ "${CFEX_NO_SKILL:-}" != "1" ]; then "$INSTALL_DIR/cfex" skill install || true; fi +echo "Installation completed! Next: cloudflared tunnel login, then: cfex dev.yourdomain.com:3000" diff --git a/internal/cf/cf.go b/internal/cf/cf.go index 024f1f7..1117e8e 100644 --- a/internal/cf/cf.go +++ b/internal/cf/cf.go @@ -153,3 +153,8 @@ func (c *Client) DNSCreate(zone string, d DNS) (out DNS, err error) { func (c *Client) DNSDelete(zone, id string) error { return c.do("DELETE", "/zones/"+zone+"/dns_records/"+id, nil, nil) } + +// DNSSetComment stamps a comment on an existing record (cloudflared cannot). +func (c *Client) DNSSetComment(zone, id, comment string) error { + return c.do("PATCH", "/zones/"+zone+"/dns_records/"+id, map[string]any{"comment": comment}, nil) +} diff --git a/internal/cfd/cfd.go b/internal/cfd/cfd.go new file mode 100644 index 0000000..78c74b5 --- /dev/null +++ b/internal/cfd/cfd.go @@ -0,0 +1,146 @@ +// Package cfd drives the cloudflared CLI: create / route dns / list / info / delete tunnels with cloudflared's own +// credentials (cert.pem from `cloudflared tunnel login`, per-tunnel credentials json). cfex only falls back to the +// Cloudflare API for things cloudflared cannot do (DNS record comments, deleting DNS records, remote ingress). +package cfd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +type Tunnel struct { + ID string `json:"id"` + Name string `json:"name"` + Connections []struct { + ID string `json:"id"` + } `json:"conns"` +} + +func (t Tunnel) Running() bool { return len(t.Connections) > 0 } + +var uuidRe = regexp.MustCompile(`[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}`) + +// Bin returns the cloudflared path. +func Bin() (string, error) { + p, err := exec.LookPath("cloudflared") + if err != nil { + return "", fmt.Errorf("cloudflared is not installed (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)") + } + return p, nil +} + +// CertPath is the origin cert written by `cloudflared tunnel login`. +func CertPath() string { + if p := os.Getenv("TUNNEL_ORIGIN_CERT"); p != "" { + return p + } + h, _ := os.UserHomeDir() + return filepath.Join(h, ".cloudflared", "cert.pem") +} + +func HasCert() bool { _, err := os.Stat(CertPath()); return err == nil } + +// ErrNoCert explains how to log in. +var ErrNoCert = fmt.Errorf("cloudflared is not logged in: run `cloudflared tunnel login` once and pick your domain") + +// ParseList parses `cloudflared tunnel list --output json` (pure, unit-tested). "null" and "" mean no tunnels. +func ParseList(b []byte) ([]Tunnel, error) { + s := strings.TrimSpace(string(b)) + if s == "" || s == "null" { + return nil, nil + } + var ts []Tunnel + if err := json.Unmarshal(b, &ts); err != nil { + return nil, fmt.Errorf("cannot parse cloudflared tunnel list: %w", err) + } + return ts, nil +} + +// ParseID pulls the tunnel id out of `cloudflared tunnel create` output. +func ParseID(out string) string { return uuidRe.FindString(out) } + +func run(args ...string) (string, error) { + bin, err := Bin() + if err != nil { + return "", err + } + out, err := exec.Command(bin, args...).CombinedOutput() + return string(out), err +} + +func List() ([]Tunnel, error) { + if !HasCert() { + return nil, ErrNoCert + } + bin, err := Bin() + if err != nil { + return nil, err + } + out, err := exec.Command(bin, "tunnel", "list", "--output", "json").Output() + if err != nil { + return nil, fmt.Errorf("cloudflared tunnel list: %w", err) + } + return ParseList(out) +} + +func Find(name string) (*Tunnel, error) { + ts, err := List() + if err != nil { + return nil, err + } + for i := range ts { + if ts[i].Name == name { + return &ts[i], nil + } + } + return nil, nil +} + +// Create makes a locally-managed tunnel; its credentials json lands in ~/.cloudflared. +func Create(name string) (string, error) { + if !HasCert() { + return "", ErrNoCert + } + out, err := run("tunnel", "create", name) + id := ParseID(out) + if err != nil || id == "" { + return "", fmt.Errorf("cloudflared tunnel create %s: %s", name, strings.TrimSpace(out)) + } + return id, nil +} + +// RouteDNS creates the CNAME. It never passes -f, so an existing record is never overwritten. +func RouteDNS(tunnel, host string) error { + out, err := run("tunnel", "route", "dns", tunnel, host) + if err != nil { + return fmt.Errorf("cloudflared tunnel route dns: %s", strings.TrimSpace(out)) + } + return nil +} + +// Delete cleans up stale connections and deletes the tunnel. +func Delete(name string) error { + run("tunnel", "cleanup", name) + if out, err := run("tunnel", "delete", "-f", name); err != nil { + return fmt.Errorf("cloudflared tunnel delete: %s", strings.TrimSpace(out)) + } + return nil +} + +func CredentialsFile(id string) string { + h, _ := os.UserHomeDir() + return filepath.Join(h, ".cloudflared", id+".json") +} + +func Version() string { + out, err := run("--version") + if err != nil { + return "" + } + return strings.TrimSpace(out) +} diff --git a/internal/cfd/cfd_test.go b/internal/cfd/cfd_test.go new file mode 100644 index 0000000..2d5c66b --- /dev/null +++ b/internal/cfd/cfd_test.go @@ -0,0 +1,25 @@ +package cfd + +import "testing" + +func TestParseList(t *testing.T) { + ts, err := ParseList([]byte(`[{"id":"a1","name":"one","conns":[{"id":"c"}]},{"id":"b2","name":"two","conns":[]}]`)) + if err != nil || len(ts) != 2 || !ts[0].Running() || ts[1].Running() { + t.Fatalf("%v %v", ts, err) + } + for _, empty := range []string{"", "null", " null\n"} { + if ts, err := ParseList([]byte(empty)); err != nil || ts != nil { + t.Fatalf("%q -> %v %v", empty, ts, err) + } + } + if _, err := ParseList([]byte("{")); err == nil { + t.Fatal("bad json must error") + } +} + +func TestParseID(t *testing.T) { + out := "Tunnel credentials written to /x/0dd3d6ba-3d8c-425f-8513-b97871c7c7d4.json\nCreated tunnel foo with id 0dd3d6ba-3d8c-425f-8513-b97871c7c7d4" + if ParseID(out) != "0dd3d6ba-3d8c-425f-8513-b97871c7c7d4" || ParseID("nothing") != "" { + t.Fatal("ParseID") + } +} diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index d4a3415..0249c03 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -13,6 +13,8 @@ import ( type Route struct { Host string `yaml:"host"` Port int `yaml:"port"` + HTTPS bool `yaml:"https,omitempty"` + VerifyTLS bool `yaml:"verify_tls,omitempty"` Zone string `yaml:"zone"` ZoneID string `yaml:"zone_id"` DNSID string `yaml:"dns_id"` @@ -38,34 +40,29 @@ type Config struct { TunnelName string `yaml:"tunnel_name"` TunnelID string `yaml:"tunnel_id,omitempty"` - // TokenEnv is the environment variable holding the Cloudflare API token (default CLOUDFLARE_API_TOKEN; - // CLOUDFLARE_API_KEY is accepted for compatibility with cfex v1). + // The Cloudflare API token is OPTIONAL: cfex drives cloudflared for tunnels and DNS routes. The token only adds + // DNS ownership checks (the managed-by comment), deleting DNS records and reading remote ingress. + // TokenEnv is the environment variable holding it (default CLOUDFLARE_API_TOKEN; CLOUDFLARE_API_KEY also works). TokenEnv string `yaml:"token_env,omitempty"` // TokenCmd is an optional command prefix that cfex re-executes itself under so the token only ever lives in // the child's environment, e.g. "op run --" or "sec run CLOUDFLARE_TOKEN --". It must export TokenEnv. TokenCmd string `yaml:"token_cmd,omitempty"` - // ServiceLabel is the launchd label / systemd unit name of the connector for the durable tunnel. - ServiceLabel string `yaml:"service_label,omitempty"` - // Connector token handling for the durable tunnel. Default: a 0600 token file under the state dir. - // Or keep it in a secret manager: ConnectorRunCmd wraps the connector ("sec run MYTOKEN --"), ConnectorEnv - // names the variable it exports, ConnectorSetCmd stores a value read from stdin ("sec set MYTOKEN"). - ConnectorRunCmd string `yaml:"connector_run_cmd,omitempty"` - ConnectorEnv string `yaml:"connector_env,omitempty"` - ConnectorSetCmd string `yaml:"connector_set_cmd,omitempty"` - - Protected []string `yaml:"protected,omitempty"` // tunnel-name globs that cfex never modifies - Client []string `yaml:"client,omitempty"` // tunnel-name globs listed as "client": never modified - Routes []Route `yaml:"routes"` - Adopted []Adopted `yaml:"adopted"` + // ServiceLabel is the launchd label / systemd unit name that keeps the durable tunnel running. + ServiceLabel string `yaml:"service_label,omitempty"` + Protected []string `yaml:"protected,omitempty"` // tunnel-name globs that cfex never modifies + Client []string `yaml:"client,omitempty"` // tunnel-name globs listed as "client": never modified + Routes []Route `yaml:"routes"` + Adopted []Adopted `yaml:"adopted"` } func Default() *Config { return &Config{TunnelName: "cfex", TokenEnv: "CLOUDFLARE_API_TOKEN", ServiceLabel: "dev.cfex.tunnel"} } -func Dir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".config", "cfex") } -func StateDir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".local", "share", "cfex") } +func Dir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".config", "cfex") } +func TunnelFile() string { return filepath.Join(Dir(), "tunnel.yml") } +func StateDir() string { h, _ := os.UserHomeDir(); return filepath.Join(h, ".local", "share", "cfex") } func Path() string { if p := os.Getenv("CFEX_CONFIG"); p != "" { return p @@ -97,15 +94,27 @@ func Load() (*Config, error) { return c, nil } -// UnderTemp reports whether path lives inside the OS temp dir (symlinks resolved). -func UnderTemp(path string) bool { - real := func(p string) string { +// resolve evaluates symlinks on the deepest existing ancestor and re-appends the missing tail, so paths that do +// not exist yet compare correctly (on macOS /var is a symlink to /private/var). +func resolve(p string) string { + p = filepath.Clean(p) + tail := "" + for { if r, err := filepath.EvalSymlinks(p); err == nil { - return r + return filepath.Join(r, tail) } - return p + parent := filepath.Dir(p) + if parent == p { + return filepath.Join(p, tail) + } + tail = filepath.Join(filepath.Base(p), tail) + p = parent } - rel, err := filepath.Rel(real(os.TempDir()), real(filepath.Dir(path))) +} + +// UnderTemp reports whether path lives inside the OS temp dir. +func UnderTemp(path string) bool { + rel, err := filepath.Rel(resolve(os.TempDir()), resolve(filepath.Dir(path))) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } diff --git a/package.json b/package.json deleted file mode 100644 index ce10cc1..0000000 --- a/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "cfex-cli", - "version": "1.0.0", - "description": "Cloudflare Tunnel CLI Tool", - "bin": { - "cfex": "./bin/cfex" - }, - "repository": "muthuishere/cfex-cli", - "license": "MIT" -} diff --git a/skills/cfex/SKILL.md b/skills/cfex/SKILL.md new file mode 100644 index 0000000..687f637 --- /dev/null +++ b/skills/cfex/SKILL.md @@ -0,0 +1,40 @@ +--- +name: cfex +description: Expose a local port on your own domain; list/stop/start/delete Cloudflare tunnels via `cfex`. Trigger: expose localhost:PORT on my domain, put a dev server on a custom domain, share a local app over HTTPS, webhook testing, list/stop/delete tunnels, what is on cloudflared. +--- + +# cfex: your local port on your own domain, through Cloudflare + +## Instant (foreground, Ctrl+C stops it and cleans up) +```bash +cfex dev.yourdomain.com:3000 # https://dev.yourdomain.com -> localhost:3000 +cfex api.yourdomain.com:8080 +cfex webhook.yourdomain.com:4000 +cfex api.yourdomain.com 8080 # same thing, two arguments +cfex secure.yourdomain.com:8443 --https [--verify-cert] # HTTPS origin (auto-detected when omitted) +cfex dev.yourdomain.com:3000 --keep # make it permanent (same as `cfex add`) +``` +Instant mode makes a throwaway cloudflared tunnel and a DNS route, and removes both on exit. + +## Durable (survives reboots, many routes on one tunnel) +```bash +cfex add 3000 dev.yourdomain.com [--https] [--protected] # launchd (macOS) / systemd user unit (Linux) +cfex list [--json] [--no-http] # EVERYTHING cloudflared-ish on this machine: host, port, tunnel, class, running, HTTP status +cfex stop|start dev.yourdomain.com +cfex delete dev.yourdomain.com [--yes] # asks y/N on a terminal; --yes for scripts; no terminal and no --yes = dry run +cfex status | doctor | troubleshoot +cfex import # adopt an existing tunnel into the config without changing what it serves +cfex skill install | uninstall # this skill +cfex uninstall # removes the binary and skill; never your tunnels unless you say so +``` + +## Setup +1. `cloudflared tunnel login` once (pick your domain). cfex drives cloudflared for tunnels and DNS routes. +2. Optional `CLOUDFLARE_API_TOKEN` (Zone:Read, DNS:Edit): adds DNS ownership checks, DNS deletion and remote ingress in `list`. `cfex doctor` says which features need it. +3. Config `~/.config/cfex/config.yaml` (default_zone, protected, client, token_cmd). State `~/.local/share/cfex/`. + +## Safety rules +- cfex only stops/deletes what it created. Tunnels matching `protected:` / `client:` in the config, adopted and unmanaged tunnels are listed but refused. +- `delete` removes only the DNS record that points at the tunnel it deletes, and never overwrites an existing record. +- Never print or paste tokens; the API token comes from the environment or `token_cmd`, never from a flag or file. +- Never run `cloudflared` or `rm` on `~/.cloudflared` to "fix" things: it holds the login cert and tunnel credentials. diff --git a/uninstall.sh b/uninstall.sh old mode 100644 new mode 100755 index 024cf96..3a0ba9f --- a/uninstall.sh +++ b/uninstall.sh @@ -1,39 +1,16 @@ #!/bin/bash - -# uninstall.sh - -# Check for sudo privileges -if [ "$EUID" -ne 0 ]; then - if command -v sudo > /dev/null; then - SUDO="sudo" - else - echo "Error: This script requires root privileges. Please run with sudo or as root." - exit 1 - fi -fi - -INSTALL_DIR="/usr/local/bin" -SCRIPT_NAME="cfex" - -# Remove the script -if [ -f "$INSTALL_DIR/$SCRIPT_NAME" ]; then - echo "Removing $SCRIPT_NAME from $INSTALL_DIR..." - $SUDO rm -f "$INSTALL_DIR/$SCRIPT_NAME" - echo "$SCRIPT_NAME has been uninstalled successfully." -else - echo "$SCRIPT_NAME is not installed in $INSTALL_DIR." +# Uninstalls cfex. Runs `cfex uninstall`, which removes the binary and agent skill and asks before touching anything else. +# Your tunnels and DNS records in Cloudflare are never deleted. +set -euo pipefail +if command -v cfex > /dev/null; then + exec cfex uninstall "$@" fi - -# Optional: Clean up cloudflared configuration -read -p "Do you want to remove cloudflared configuration files? (y/N) " response -if [[ "$response" =~ ^[Yy]$ ]]; then - if [ -d "$HOME/.cloudflared" ]; then - echo "Removing cloudflared configuration files..." - rm -rf "$HOME/.cloudflared" - echo "Cloudflared configuration files have been removed." - else - echo "No cloudflared configuration files found." +for d in /usr/local/bin "$HOME/.local/bin"; do + if [ -f "$d/cfex" ]; then + echo "Removing $d/cfex..." + if [ -w "$d" ]; then rm -f "$d/cfex"; else sudo rm -f "$d/cfex"; fi + echo "cfex has been uninstalled successfully." + exit 0 fi -fi - -echo "Uninstallation completed!" +done +echo "cfex is not installed."