main pico / pkg / apps / pico / cli.go
Antonio Mika  ·  2026-10-11
  1package pico
  2
  3import (
  4	"bufio"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"strings"
 10	"time"
 11
 12	"github.com/picosh/pico/pkg/db"
 13	"github.com/picosh/pico/pkg/pssh"
 14	"github.com/picosh/pico/pkg/shared"
 15
 16	pipeLogger "github.com/picosh/utils/pipe/log"
 17)
 18
 19func getUser(s *pssh.SSHServerConnSession, dbpool db.DB) (*db.User, error) {
 20	if s.PublicKey() == nil {
 21		return nil, fmt.Errorf("key not found")
 22	}
 23
 24	key := shared.KeyForKeyText(s.PublicKey())
 25
 26	user, err := dbpool.FindUserByKey(s.User(), key)
 27	if err != nil {
 28		return nil, err
 29	}
 30
 31	if user.Name == "" {
 32		return nil, fmt.Errorf("must have username set")
 33	}
 34
 35	return user, nil
 36}
 37
 38type Cmd struct {
 39	User       *db.User
 40	SshSession *pssh.SSHServerConnSession
 41	Session    shared.CmdSession
 42	Log        *slog.Logger
 43	Dbpool     db.DB
 44	Write      bool
 45
 46	// DomainTargets are the hosts dns-check expects a custom domain to point at.
 47	DomainTargets []string
 48
 49	lookupTXT  func(host, space string) ([]string, error)
 50	lookupHost func(host string) (cname string, addrs []string, err error)
 51}
 52
 53func (c *Cmd) output(out string) {
 54	_, _ = c.Session.Write([]byte(out + "\r\n"))
 55}
 56
 57func (c *Cmd) help() {
 58	helpStr := "Commands: [help, user, logs, access-logs, chat, not-found, dns-check]\n"
 59	helpStr += "help - this message\n"
 60	helpStr += "user - display user information (returns name, id, account created, pico+ expiration)\n"
 61	helpStr += "logs - stream user logs\n"
 62	helpStr += "access-logs - fetch access logs from the last 30 days\n"
 63	helpStr += "chat - IRC chat (must enable pty with `-t` to the SSH command)\n"
 64	helpStr += "not-found - return all status 404 requests for a host (hostname.com [year|month])\n"
 65	helpStr += "dns-check - check a domain's CNAME/A and TXT records for pgs, prose and tuns custom domains (hostname.com or *.hostname.com)\n"
 66	c.output(helpStr)
 67}
 68
 69func (c *Cmd) user() {
 70	plus := ""
 71	ff, _ := c.Dbpool.FindFeature(c.User.ID, "plus")
 72	if ff != nil {
 73		plus = ff.ExpiresAt.Format(time.RFC3339)
 74	}
 75	helpStr := fmt.Sprintf(
 76		"%s\n%s\n%s\n%s",
 77		c.User.Name,
 78		c.User.ID,
 79		c.User.CreatedAt.Format(time.RFC3339),
 80		plus,
 81	)
 82	c.output(helpStr)
 83}
 84
 85func (c *Cmd) notFound(host, interval string) error {
 86	origin := shared.StartOfYear()
 87	if interval == "month" {
 88		origin = shared.StartOfMonth()
 89	}
 90	c.output(fmt.Sprintf("starting from: %s\n", origin.Format(time.RFC3339)))
 91	urls, err := c.Dbpool.VisitUrlNotFound(&db.SummaryOpts{
 92		Host:   host,
 93		UserID: c.User.ID,
 94		Limit:  100,
 95		Origin: origin,
 96	})
 97	if err != nil {
 98		return err
 99	}
100	for _, url := range urls {
101		c.output(fmt.Sprintf("%d %s", url.Count, url.Url))
102	}
103	return nil
104}
105
106func (c *Cmd) accessLogs(_ context.Context) error {
107	fromDate := time.Now().AddDate(0, 0, -30)
108	logs, err := c.Dbpool.FindAccessLogs(c.User.ID, &fromDate)
109	if err != nil {
110		return err
111	}
112
113	for _, log := range logs {
114		jsonData, err := json.Marshal(log)
115		if err != nil {
116			c.Log.Error("json marshall", "err", err)
117			continue
118		}
119		c.output(string(jsonData))
120	}
121	return nil
122}
123
124func (c *Cmd) logs(ctx context.Context) error {
125	conn := shared.NewPicoPipeClient()
126	stdoutPipe, err := pipeLogger.ReadLogs(ctx, c.Log, conn)
127
128	if err != nil {
129		return err
130	}
131
132	logChan := make(chan string)
133	defer close(logChan)
134
135	go func() {
136		for {
137			select {
138			case <-ctx.Done():
139				return
140			case log, ok := <-logChan:
141				if log == "" {
142					continue
143				}
144				if !ok {
145					return
146				}
147				_, _ = fmt.Fprintln(c.SshSession, log)
148			}
149		}
150	}()
151
152	scanner := bufio.NewScanner(stdoutPipe)
153	scanner.Buffer(make([]byte, 32*1024), 32*1024)
154	for scanner.Scan() {
155		line := scanner.Text()
156		parsedData := map[string]any{}
157
158		err := json.Unmarshal([]byte(line), &parsedData)
159		if err != nil {
160			c.Log.Error("json unmarshal", "err", err, "line", line, "hidden", true)
161			continue
162		}
163
164		user := shared.AnyToStr(parsedData, "user")
165		userId := shared.AnyToStr(parsedData, "userId")
166
167		hidden := shared.AnyToBool(parsedData, "hidden")
168
169		if !hidden && (user == c.User.Name || userId == c.User.ID) {
170			select {
171			case logChan <- line:
172			case <-ctx.Done():
173				return nil
174			default:
175				c.Log.Error("logChan is full, dropping log", "log", line)
176				continue
177			}
178		}
179	}
180	return scanner.Err()
181}
182
183type CliHandler struct {
184	DBPool        db.DB
185	Logger        *slog.Logger
186	DomainTargets []string
187}
188
189func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
190	dbpool := handler.DBPool
191	log := handler.Logger
192
193	return func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
194		return func(sesh *pssh.SSHServerConnSession) error {
195			args := sesh.Command()
196			if len(args) == 0 {
197				return next(sesh)
198			}
199
200			user, err := getUser(sesh, dbpool)
201			if err != nil {
202				_, _ = fmt.Fprintf(sesh.Stderr(), "detected ssh command: %s\n", args)
203				s := fmt.Errorf("ERROR: you need to create an account before using the remote cli: %w", err)
204				sesh.Fatal(s)
205				return s
206			}
207
208			if len(args) > 0 && args[0] == "chat" {
209				_, _, hasPty := sesh.Pty()
210				if !hasPty {
211					err := fmt.Errorf(
212						"in order to render chat you need to enable PTY with the `ssh -t` flag",
213					)
214
215					sesh.Fatal(err)
216					return err
217				}
218
219				ff, err := dbpool.FindFeature(user.ID, "plus")
220				if err != nil {
221					handler.Logger.Error("Unable to find plus feature flag", "err", err, "user", user, "command", args)
222					ff, err = dbpool.FindFeature(user.ID, "bouncer")
223					if err != nil {
224						handler.Logger.Error("Unable to find bouncer feature flag", "err", err, "user", user, "command", args)
225						sesh.Fatal(err)
226						return err
227					}
228				}
229
230				if ff == nil {
231					err = fmt.Errorf("unable to find plus or bouncer feature flag")
232					sesh.Fatal(err)
233					return err
234				}
235
236				pass, err := dbpool.UpsertToken(user.ID, "pico-chat")
237				if err != nil {
238					sesh.Fatal(err)
239					return err
240				}
241				app, err := shared.NewSenpaiApp(sesh, user.Name, pass)
242				if err != nil {
243					sesh.Fatal(err)
244					return err
245				}
246				app.Run()
247				app.Close()
248				return err
249			}
250
251			opts := Cmd{
252				Session:    sesh,
253				SshSession: sesh,
254				User:       user,
255				Log:        log,
256				Dbpool:     dbpool,
257				Write:      false,
258
259				DomainTargets: handler.DomainTargets,
260			}
261
262			cmd := strings.TrimSpace(args[0])
263			if len(args) == 1 {
264				switch cmd {
265				case "help":
266					opts.help()
267					return nil
268				case "user":
269					opts.user()
270					return nil
271				case "logs":
272					err = opts.logs(sesh.Context())
273					if err != nil {
274						sesh.Fatal(err)
275					}
276					return nil
277				case "dns-check":
278					sesh.Fatal(fmt.Errorf("must provide a domain name (`dns-check example.com`)"))
279					return nil
280				// access_logs is the command's old name.
281				case "access-logs", "access_logs":
282					err = opts.accessLogs(sesh.Context())
283					if err != nil {
284						sesh.Fatal(err)
285					}
286					return nil
287				default:
288					return next(sesh)
289				}
290			}
291
292			if cmd == "dns-check" {
293				err = opts.dnsCheck(args[1])
294				if err != nil {
295					sesh.Fatal(err)
296				}
297				return nil
298			}
299
300			if cmd == "not-found" {
301				if len(args) < 3 {
302					sesh.Fatal(fmt.Errorf("must provide host name and interval (`month` or `year`)"))
303					return nil
304				}
305				err = opts.notFound(args[1], args[2])
306				if err != nil {
307					sesh.Fatal(err)
308				}
309				return nil
310			}
311
312			return next(sesh)
313		}
314	}
315}