repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
3ceca3b
parent
708c969
author
Eric Bower
date
2024-04-29 19:21:02 +0000 UTC
feat: pico ssh app (#126)

21 files changed,  +738, -85
M .env.example
+15, -0
 1@@ -116,6 +116,21 @@ PGS_PROTOCOL=http
 2 PGS_STORAGE_DIR=.storage
 3 PGS_DEBUG=1
 4 
 5+PICO_CADDYFILE=./caddy/Caddyfile.pico
 6+PICO_V4=
 7+PICO_V6=
 8+PICO_HTTP_V4=$PICO_V4:80
 9+PICO_HTTP_V6=[$PICO_V6]:80
10+PICO_HTTPS_V4=$PICO_V4:443
11+PICO_HTTPS_V6=[$PICO_V6]:443
12+PICO_SSH_V4=$PICO_V4:22
13+PICO_SSH_V6=[$PICO_V6]:22
14+PICO_HOST=
15+PICO_SSH_PORT=2222
16+PICO_PROM_PORT=9222
17+PICO_DOMAIN=pico.sh
18+PICO_EMAIL=hello@pico.sh
19+
20 AUTH_V4=
21 AUTH_V6=
22 AUTH_IRCS_V4=$AUTH_V4:6697
M Dockerfile
+12, -1
 1@@ -25,7 +25,18 @@ ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
 2 
 3 RUN go build -ldflags "$LDFLAGS" -o /go/bin/${APP}-web ./cmd/${APP}/web
 4 
 5-FROM builder-web as builder-ssh
 6+FROM builder-deps as builder-ssh
 7+
 8+COPY . .
 9+
10+ARG APP=prose
11+ARG TARGETOS
12+ARG TARGETARCH
13+
14+ENV CGO_ENABLED=0
15+ENV LDFLAGS="-s -w"
16+
17+ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
18 
19 RUN go build -ldflags "$LDFLAGS" -o /go/bin/${APP}-ssh ./cmd/${APP}/ssh
20 
M Makefile
+9, -11
 1@@ -36,6 +36,10 @@ bp-auth: bp-setup
 2 	$(DOCKER_BUILDX_BUILD) -t ghcr.io/picosh/pico/auth-web:$(DOCKER_TAG) --build-arg APP=auth --target release-web .
 3 .PHONY: bp-auth
 4 
 5+bp-pico: bp-setup
 6+	$(DOCKER_BUILDX_BUILD) -t ghcr.io/picosh/pico/pico-ssh:$(DOCKER_TAG) --build-arg APP=pico --target release-ssh .
 7+.PHONY: bp-auth
 8+
 9 bp-bouncer: bp-setup
10 	$(DOCKER_BUILDX_BUILD) -t ghcr.io/picosh/pico/bouncer:$(DOCKER_TAG) ./bouncer
11 .PHONY: bp-bouncer
12@@ -52,26 +56,20 @@ bp-%: bp-setup
13 bp-all: bp-prose bp-pastes bp-imgs bp-feeds bp-pgs bp-auth bp-bouncer
14 .PHONY: bp-all
15 
16-bp-podman-%:
17-	$(DOCKER_CMD) buildx build --platform $(DOCKER_PLATFORM) --build-arg "APP=$*" -t "ghcr.io/picosh/pico/$*-ssh:$(DOCKER_TAG)" --target release-ssh .
18-	$(DOCKER_CMD) buildx build --platform $(DOCKER_PLATFORM) --build-arg "APP=$*" -t "ghcr.io/picosh/pico/$*-web:$(DOCKER_TAG)" --target release-web .
19-	$(DOCKER_CMD) push "ghcr.io/picosh/pico/$*-ssh:$(DOCKER_TAG)"
20-	$(DOCKER_CMD) push "ghcr.io/picosh/pico/$*-web:$(DOCKER_TAG)"
21-.PHONY: bp-%
22-
23-bp-podman-all: bp-podman-prose bp-podman-pastes bp-podman-imgs bp-podman-feeds bp-podman-pgs
24-.PHONY: all
25-
26 build-auth:
27 	go build -o "build/auth" "./cmd/auth/web"
28 .PHONY: build-auth
29 
30+build-pico:
31+	go build -o "build/pico-ssh" "./cmd/pico/ssh"
32+.PHONY: build-auth
33+
34 build-%:
35 	go build -o "build/$*-web" "./cmd/$*/web"
36 	go build -o "build/$*-ssh" "./cmd/$*/ssh"
37 .PHONY: build-%
38 
39-build: build-prose build-pastes build-imgs build-feeds build-pgs build-auth
40+build: build-prose build-pastes build-imgs build-feeds build-pgs build-auth build-pico
41 .PHONY: build
42 
43 store-clean:
R auth/auth.go => auth/api.go
+0, -0
A caddy/Caddyfile.pico
+10, -0
 1@@ -0,0 +1,10 @@
 2+{$APP_DOMAIN}, tmp.pico.sh {
 3+  reverse_proxy https://pico-docs-prod.pgs.sh {
 4+    header_up Host pico-docs-prod.pgs.sh
 5+  }
 6+
 7+  tls {$APP_EMAIL} {
 8+		dns cloudflare {$CF_API_TOKEN}
 9+		resolvers 1.1.1.1
10+	}
11+}
A cmd/pico/ssh/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "github.com/picosh/pico/pico"
5+
6+func main() {
7+	pico.StartSshServer()
8+}
M db/postgres/storage.go
+1, -1
1@@ -136,7 +136,7 @@ var (
2 
3 const (
4 	sqlSelectPublicKey         = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`
5-	sqlSelectPublicKeys        = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE user_id = $1`
6+	sqlSelectPublicKeys        = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`
7 	sqlSelectUser              = `SELECT id, name, created_at FROM app_users WHERE id = $1`
8 	sqlSelectUserForName       = `SELECT id, name, created_at FROM app_users WHERE name = $1`
9 	sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
M docker-compose.override.yml
+11, -0
 1@@ -151,6 +151,17 @@ services:
 2       - ./data/feeds-ssh/data:/app/ssh_data
 3     ports:
 4       - "2225:2222"
 5+  pico-ssh:
 6+    build:
 7+      args:
 8+        APP: pico
 9+      target: release-ssh
10+    env_file:
11+      - .env.example
12+    volumes:
13+      - ./data/pico-ssh/data:/app/ssh_data
14+    ports:
15+      - "2226:2222"
16   auth-web:
17     build:
18       args:
M docker-compose.prod.yml
+41, -0
 1@@ -304,6 +304,41 @@ services:
 2     ports:
 3       - "${FEEDS_SSH_V4:-22}:2222"
 4       - "${FEEDS_SSH_V6:-[::1]:22}:2222"
 5+  pico-caddy:
 6+    image: ghcr.io/picosh/pico/caddy:latest
 7+    restart: always
 8+    networks:
 9+      - pico
10+    env_file:
11+      - .env.prod
12+    environment:
13+      APP_DOMAIN: ${PICO_DOMAIN:-pico.sh}
14+      APP_EMAIL: ${PICO_EMAIL:-hello@pico.sh}
15+    volumes:
16+      - ${PICO_CADDYFILE}:/etc/caddy/Caddyfile
17+      - ./data/pico-caddy/data:/data
18+      - ./data/pico-caddy/config:/config
19+    ports:
20+      - "${PICO_HTTPS_V4:-443}:443"
21+      - "${PICO_HTTP_V4:-80}:80"
22+      - "${PICO_HTTPS_V6:-[::1]:443}:443"
23+      - "${PICO_HTTP_V6:-[::1]:80}:80"
24+    profiles:
25+      - pico
26+      - caddy
27+      - all
28+  pico-ssh:
29+    networks:
30+      pico:
31+        aliases:
32+          - ssh
33+    env_file:
34+      - .env.prod
35+    volumes:
36+      - ./data/pico-ssh/data:/app/ssh_data
37+    ports:
38+      - "${PICO_SSH_V4:-22}:2222"
39+      - "${PICO_SSH_V6:-[::1]:22}:2222"
40 
41 networks:
42   default:
43@@ -347,3 +382,9 @@ networks:
44     ipam:
45       config:
46         - subnet: 172.23.0.0/16
47+  pico:
48+    driver_opts:
49+      com.docker.network.bridge.name: pico
50+    ipam:
51+      config:
52+        - subnet: 172.24.0.0/16
M docker-compose.yml
+7, -0
 1@@ -115,6 +115,13 @@ services:
 2       - feeds
 3       - services
 4       - all
 5+  pico-ssh:
 6+    image: ghcr.io/picosh/pico/pico-ssh:latest
 7+    restart: always
 8+    profiles:
 9+      - pico
10+      - services
11+      - all
12   auth-web:
13     image: ghcr.io/picosh/pico/auth-web:latest
14     restart: always
M filehandlers/router_handler.go
+9, -5
 1@@ -66,7 +66,7 @@ func (r *FileHandlerRouter) Read(s ssh.Session, entry *utils.FileEntry) (os.File
 2 	return handler.Read(s, entry)
 3 }
 4 
 5-func (r *FileHandlerRouter) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 6+func BaseList(s ssh.Session, fpath string, isDir bool, recursive bool, spaces []string, dbpool db.DB) ([]os.FileInfo, error) {
 7 	var fileList []os.FileInfo
 8 	user, err := util.GetUser(s)
 9 	if err != nil {
10@@ -88,8 +88,8 @@ func (r *FileHandlerRouter) List(s ssh.Session, fpath string, isDir bool, recurs
11 			FIsDir: true,
12 		})
13 
14-		for _, space := range r.Spaces {
15-			curPosts, e := r.DBPool.FindAllPostsForUser(user.ID, space)
16+		for _, space := range spaces {
17+			curPosts, e := dbpool.FindAllPostsForUser(user.ID, space)
18 			if e != nil {
19 				err = e
20 				break
21@@ -97,8 +97,8 @@ func (r *FileHandlerRouter) List(s ssh.Session, fpath string, isDir bool, recurs
22 			posts = append(posts, curPosts...)
23 		}
24 	} else {
25-		for _, space := range r.Spaces {
26-			p, e := r.DBPool.FindPostWithFilename(cleanFilename, user.ID, space)
27+		for _, space := range spaces {
28+			p, e := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
29 			if e != nil {
30 				err = e
31 				continue
32@@ -125,6 +125,10 @@ func (r *FileHandlerRouter) List(s ssh.Session, fpath string, isDir bool, recurs
33 	return fileList, nil
34 }
35 
36+func (r *FileHandlerRouter) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
37+	return BaseList(s, fpath, isDir, recursive, r.Spaces, r.DBPool)
38+}
39+
40 func (r *FileHandlerRouter) GetLogger() *slog.Logger {
41 	return r.Cfg.Logger
42 }
R imgs/wish.go => imgs/cli.go
+0, -0
M pgs/ssh.go
+1, -3
 1@@ -11,13 +11,11 @@ import (
 2 	"github.com/charmbracelet/promwish"
 3 	"github.com/charmbracelet/ssh"
 4 	"github.com/charmbracelet/wish"
 5-	bm "github.com/charmbracelet/wish/bubbletea"
 6 	"github.com/picosh/pico/db"
 7 	"github.com/picosh/pico/db/postgres"
 8 	uploadassets "github.com/picosh/pico/filehandlers/assets"
 9 	"github.com/picosh/pico/shared"
10 	"github.com/picosh/pico/shared/storage"
11-	"github.com/picosh/pico/tui"
12 	wsh "github.com/picosh/pico/wish"
13 	"github.com/picosh/ptun"
14 	"github.com/picosh/send/list"
15@@ -42,7 +40,7 @@ func createRouter(cfg *shared.ConfigSite, handler *uploadassets.UploadAssetHandl
16 			scp.Middleware(handler),
17 			wishrsync.Middleware(handler),
18 			auth.Middleware(handler),
19-			wsh.PtyMdw(bm.Middleware(tui.CmsMiddleware(cfg))),
20+			wsh.PtyMdw(wsh.DeprecatedNotice()),
21 			WishMiddleware(handler),
22 			wsh.LogMiddleware(handler.GetLogger()),
23 		}
A pico/cli.go
+164, -0
  1@@ -0,0 +1,164 @@
  2+package pico
  3+
  4+import (
  5+	"fmt"
  6+	"log/slog"
  7+	"strings"
  8+
  9+	"github.com/charmbracelet/ssh"
 10+	"github.com/charmbracelet/wish"
 11+	"github.com/picosh/pico/db"
 12+	"github.com/picosh/pico/shared"
 13+	"github.com/picosh/pico/tui/common"
 14+	"github.com/picosh/send/send/utils"
 15+)
 16+
 17+func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
 18+	var err error
 19+	key, err := shared.KeyText(s)
 20+	if err != nil {
 21+		return nil, fmt.Errorf("key not found")
 22+	}
 23+
 24+	user, err := dbpool.FindUserForKey(s.User(), key)
 25+	if err != nil {
 26+		return nil, err
 27+	}
 28+
 29+	if user.Name == "" {
 30+		return nil, fmt.Errorf("must have username set")
 31+	}
 32+
 33+	return user, nil
 34+}
 35+
 36+type Cmd struct {
 37+	User    *db.User
 38+	Session shared.CmdSession
 39+	Log     *slog.Logger
 40+	Dbpool  db.DB
 41+	Write   bool
 42+	Styles  common.Styles
 43+}
 44+
 45+func (c *Cmd) output(out string) {
 46+	_, _ = c.Session.Write([]byte(out + "\r\n"))
 47+}
 48+
 49+func (c *Cmd) help() {
 50+	helpStr := "Commands: [help, pico+]\n"
 51+	c.output(helpStr)
 52+}
 53+
 54+func (c *Cmd) plus() {
 55+	clientRefId := c.User.Name
 56+	paymentLink := "https://buy.stripe.com/6oEaIvaNq7DA4NO9AD"
 57+	url := fmt.Sprintf("%s?client_reference_id=%s", paymentLink, clientRefId)
 58+	md := fmt.Sprintf(`# pico+
 59+
 60+Signup to get premium access
 61+
 62+## $2/month (billed annually)
 63+
 64+Includes:
 65+- pgs.sh - 10GB asset storage
 66+- tuns.sh - full access
 67+- imgs.sh - 5GB image registry storage
 68+- prose.sh - 1GB image storage
 69+- prose.sh - 1GB image storage
 70+- beta access - Invited to join our private IRC channel
 71+
 72+There are a few ways to purchase a membership. We try our best to
 73+provide immediate access to pico+ regardless of payment method.
 74+
 75+## Stripe (US/CA Only)
 76+
 77+%s
 78+
 79+This is the quickest way to access pico+. The Stripe payment
 80+method requires an email address. We will never use your email
 81+for anything unless absolutely necessary.
 82+
 83+## Snail Mail
 84+
 85+Send cash (USD) or check to:
 86+- pico.sh LLC
 87+- 206 E Huron St STE 103
 88+- Ann Arbor MI 48104
 89+
 90+Message us when payment is in transit and we will grant you
 91+temporary access topico+ that will be converted to a full
 92+year after we received it.
 93+
 94+## Notes
 95+
 96+Have any questions not covered here? Email us or join IRC,
 97+we will promptly respond.
 98+
 99+Unfortunately we do not have the labor bandwidth to support
100+international users for pico+ at this time. As a result,
101+we only offer our premium services to the US and Canada.
102+
103+We do not maintain active subscriptions for pico+. Every
104+year you must pay again. We do not take monthly payments,
105+you must pay for a year up-front. Pricing is subject to
106+change because we plan on continuing to include more services
107+as we build them.
108+
109+Need higher limits? We are more than happy to extend limits.
110+Just message us and we can chat.
111+`, url)
112+
113+	c.output(md)
114+}
115+
116+type CliHandler struct {
117+	DBPool db.DB
118+	Logger *slog.Logger
119+}
120+
121+func WishMiddleware(handler *CliHandler) wish.Middleware {
122+	dbpool := handler.DBPool
123+	log := handler.Logger
124+
125+	return func(next ssh.Handler) ssh.Handler {
126+		return func(sesh ssh.Session) {
127+			user, err := getUser(sesh, dbpool)
128+			if err != nil {
129+				utils.ErrorHandler(sesh, err)
130+				return
131+			}
132+
133+			args := sesh.Command()
134+
135+			opts := Cmd{
136+				Session: sesh,
137+				User:    user,
138+				Log:     log,
139+				Dbpool:  dbpool,
140+				Write:   false,
141+			}
142+
143+			if len(args) == 0 {
144+				next(sesh)
145+				return
146+			}
147+
148+			cmd := strings.TrimSpace(args[0])
149+			if len(args) == 1 {
150+				if cmd == "help" {
151+					opts.help()
152+					return
153+				} else if cmd == "pico+" {
154+					opts.plus()
155+					return
156+				} else {
157+					next(sesh)
158+					return
159+				}
160+			}
161+
162+			next(sesh)
163+		}
164+	}
165+}
A pico/config.go
+15, -0
 1@@ -0,0 +1,15 @@
 2+package pico
 3+
 4+import (
 5+	"github.com/picosh/pico/shared"
 6+)
 7+
 8+func NewConfigSite() *shared.ConfigSite {
 9+	dbURL := shared.GetEnv("DATABASE_URL", "")
10+
11+	return &shared.ConfigSite{
12+		DbURL:  dbURL,
13+		Space:  "pico",
14+		Logger: shared.CreateLogger(false),
15+	}
16+}
A pico/file_handler.go
+273, -0
  1@@ -0,0 +1,273 @@
  2+package pico
  3+
  4+import (
  5+	"bytes"
  6+	"fmt"
  7+	"io"
  8+	"log/slog"
  9+	"os"
 10+	"path/filepath"
 11+	"strings"
 12+	"time"
 13+
 14+	"github.com/charmbracelet/ssh"
 15+	"github.com/picosh/pico/db"
 16+	"github.com/picosh/pico/filehandlers/util"
 17+	"github.com/picosh/pico/shared"
 18+	"github.com/picosh/send/send/utils"
 19+)
 20+
 21+type UploadHandler struct {
 22+	DBPool db.DB
 23+	Cfg    *shared.ConfigSite
 24+}
 25+
 26+func NewUploadHandler(dbpool db.DB, cfg *shared.ConfigSite) *UploadHandler {
 27+	return &UploadHandler{
 28+		DBPool: dbpool,
 29+		Cfg:    cfg,
 30+	}
 31+}
 32+
 33+func (h *UploadHandler) getAuthorizedKeyFile(user *db.User) (*utils.VirtualFile, string, error) {
 34+	keys, err := h.DBPool.FindKeysForUser(user)
 35+	text := ""
 36+	var modTime time.Time
 37+	for _, pk := range keys {
 38+		text += fmt.Sprintf("%s %s\n", pk.Key, pk.Name)
 39+		modTime = *pk.CreatedAt
 40+	}
 41+	if err != nil {
 42+		return nil, "", err
 43+	}
 44+	fileInfo := &utils.VirtualFile{
 45+		FName:    "authorized_keys",
 46+		FIsDir:   false,
 47+		FSize:    int64(len(text)),
 48+		FModTime: modTime,
 49+	}
 50+	return fileInfo, text, nil
 51+}
 52+
 53+func (h *UploadHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, utils.ReaderAtCloser, error) {
 54+	user, err := util.GetUser(s)
 55+	if err != nil {
 56+		return nil, nil, err
 57+	}
 58+	cleanFilename := filepath.Base(entry.Filepath)
 59+
 60+	if cleanFilename == "" || cleanFilename == "." {
 61+		return nil, nil, os.ErrNotExist
 62+	}
 63+
 64+	if cleanFilename == "authorized_keys" {
 65+		fileInfo, text, err := h.getAuthorizedKeyFile(user)
 66+		if err != nil {
 67+			return nil, nil, err
 68+		}
 69+		reader := utils.NopReaderAtCloser(strings.NewReader(text))
 70+		return fileInfo, reader, nil
 71+	}
 72+
 73+	return nil, nil, os.ErrNotExist
 74+}
 75+
 76+func (h *UploadHandler) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 77+	var fileList []os.FileInfo
 78+	user, err := util.GetUser(s)
 79+	if err != nil {
 80+		return fileList, err
 81+	}
 82+	cleanFilename := filepath.Base(fpath)
 83+
 84+	if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
 85+		name := cleanFilename
 86+		if name == "" {
 87+			name = "/"
 88+		}
 89+
 90+		fileList = append(fileList, &utils.VirtualFile{
 91+			FName:  name,
 92+			FIsDir: true,
 93+		})
 94+
 95+		flist, _, err := h.getAuthorizedKeyFile(user)
 96+		if err != nil {
 97+			return fileList, err
 98+		}
 99+		fileList = append(fileList, flist)
100+	} else {
101+		if cleanFilename == "authorized_keys" {
102+			flist, _, err := h.getAuthorizedKeyFile(user)
103+			if err != nil {
104+				return fileList, err
105+			}
106+			fileList = append(fileList, flist)
107+		}
108+	}
109+
110+	return fileList, nil
111+}
112+
113+func (h *UploadHandler) GetLogger() *slog.Logger {
114+	return h.Cfg.Logger
115+}
116+
117+func (h *UploadHandler) Validate(s ssh.Session) error {
118+	var err error
119+	key, err := utils.KeyText(s)
120+	if err != nil {
121+		return fmt.Errorf("key not found")
122+	}
123+
124+	user, err := h.DBPool.FindUserForKey(s.User(), key)
125+	if err != nil {
126+		return err
127+	}
128+
129+	if user.Name == "" {
130+		return fmt.Errorf("must have username set")
131+	}
132+
133+	util.SetUser(s, user)
134+	return nil
135+}
136+
137+type KeyWithId struct {
138+	Pk      ssh.PublicKey
139+	ID      string
140+	Comment string
141+}
142+
143+type KeyDiffResult struct {
144+	Add []KeyWithId
145+	Rm  []string
146+}
147+
148+func authorizedKeysDiff(keyInUse ssh.PublicKey, curKeys []KeyWithId, nextKeys []KeyWithId) KeyDiffResult {
149+	add := []KeyWithId{}
150+	for _, nk := range nextKeys {
151+		found := false
152+		for _, ck := range curKeys {
153+			if ssh.KeysEqual(nk.Pk, ck.Pk) {
154+				found = true
155+				break
156+			}
157+		}
158+		if !found {
159+			add = append(add, nk)
160+		}
161+	}
162+
163+	rm := []string{}
164+	for _, ck := range curKeys {
165+		// we never want to remove the key that's in the current ssh session
166+		// in an effort to avoid mistakenly removing their current key
167+		if ssh.KeysEqual(ck.Pk, keyInUse) {
168+			continue
169+		}
170+
171+		found := false
172+		for _, nk := range nextKeys {
173+			if ssh.KeysEqual(ck.Pk, nk.Pk) {
174+				found = true
175+				break
176+			}
177+		}
178+		if !found {
179+			rm = append(rm, ck.ID)
180+		}
181+	}
182+
183+	return KeyDiffResult{
184+		Add: add,
185+		Rm:  rm,
186+	}
187+}
188+
189+func (h *UploadHandler) ProcessAuthorizedKeys(text []byte, logger *slog.Logger, user *db.User, s ssh.Session) error {
190+	dbpool := h.DBPool
191+
192+	curKeysStr, err := dbpool.FindKeysForUser(user)
193+	if err != nil {
194+		return err
195+	}
196+
197+	splitKeys := bytes.Split(text, []byte{'\n'})
198+	nextKeys := []KeyWithId{}
199+	for _, pk := range splitKeys {
200+		key, comment, _, _, err := ssh.ParseAuthorizedKey(bytes.TrimSpace(pk))
201+		if err != nil {
202+			continue
203+		}
204+		nextKeys = append(nextKeys, KeyWithId{Pk: key, Comment: comment})
205+	}
206+
207+	curKeys := []KeyWithId{}
208+	for _, pk := range curKeysStr {
209+		key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
210+		if err != nil {
211+			continue
212+		}
213+		curKeys = append(curKeys, KeyWithId{Pk: key, ID: pk.ID})
214+	}
215+
216+	diff := authorizedKeysDiff(s.PublicKey(), curKeys, nextKeys)
217+
218+	for _, pk := range diff.Add {
219+		key, err := shared.KeyForKeyText(pk.Pk)
220+		if err != nil {
221+			continue
222+		}
223+
224+		logger.Info("adding pubkey for user", "pubkey", key)
225+
226+		_, err = dbpool.InsertPublicKey(user.ID, key, pk.Comment, nil)
227+		if err != nil {
228+			logger.Error("could not insert pubkey", "err", err.Error())
229+		}
230+	}
231+
232+	if len(diff.Rm) > 0 {
233+		logger.Info("removing pubkeys for user", "pubkeys", diff.Rm)
234+
235+		err = dbpool.RemoveKeys(diff.Rm)
236+		if err != nil {
237+			logger.Error("could not remove pubkey", "err", err.Error())
238+		}
239+	}
240+
241+	return nil
242+}
243+
244+func (h *UploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
245+	logger := h.Cfg.Logger
246+	user, err := util.GetUser(s)
247+	if err != nil {
248+		logger.Error(err.Error())
249+		return "", err
250+	}
251+
252+	filename := filepath.Base(entry.Filepath)
253+	logger = logger.With(
254+		"user", user.Name,
255+		"filename", filename,
256+		"space", h.Cfg.Space,
257+	)
258+
259+	var text []byte
260+	if b, err := io.ReadAll(entry.Reader); err == nil {
261+		text = b
262+	}
263+
264+	if filename == "authorized_keys" {
265+		err := h.ProcessAuthorizedKeys(text, logger, user, s)
266+		if err != nil {
267+			return "", err
268+		}
269+	} else {
270+		return "", fmt.Errorf("validation error: invalid file, received %s", entry.Filepath)
271+	}
272+
273+	return "", nil
274+}
A pico/ssh.go
+111, -0
  1@@ -0,0 +1,111 @@
  2+package pico
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"os"
  8+	"os/signal"
  9+	"syscall"
 10+	"time"
 11+
 12+	"github.com/charmbracelet/promwish"
 13+	"github.com/charmbracelet/ssh"
 14+	"github.com/charmbracelet/wish"
 15+	bm "github.com/charmbracelet/wish/bubbletea"
 16+	"github.com/picosh/pico/db/postgres"
 17+	"github.com/picosh/pico/shared"
 18+	"github.com/picosh/pico/tui"
 19+	wsh "github.com/picosh/pico/wish"
 20+	"github.com/picosh/send/list"
 21+	"github.com/picosh/send/pipe"
 22+	"github.com/picosh/send/proxy"
 23+	"github.com/picosh/send/send/auth"
 24+	wishrsync "github.com/picosh/send/send/rsync"
 25+	"github.com/picosh/send/send/scp"
 26+	"github.com/picosh/send/send/sftp"
 27+)
 28+
 29+func authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 30+	shared.SetPublicKeyCtx(ctx, key)
 31+	return true
 32+}
 33+
 34+func createRouter(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler) proxy.Router {
 35+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 36+		return []wish.Middleware{
 37+			pipe.Middleware(handler, ""),
 38+			list.Middleware(handler),
 39+			scp.Middleware(handler),
 40+			wishrsync.Middleware(handler),
 41+			auth.Middleware(handler),
 42+			wsh.PtyMdw(bm.Middleware(tui.CmsMiddleware(cfg))),
 43+			WishMiddleware(cliHandler),
 44+			wsh.LogMiddleware(handler.GetLogger()),
 45+		}
 46+	}
 47+}
 48+
 49+func withProxy(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler, otherMiddleware ...wish.Middleware) ssh.Option {
 50+	return func(server *ssh.Server) error {
 51+		err := sftp.SSHOption(handler)(server)
 52+		if err != nil {
 53+			return err
 54+		}
 55+
 56+		return proxy.WithProxy(createRouter(cfg, handler, cliHandler), otherMiddleware...)(server)
 57+	}
 58+}
 59+
 60+func StartSshServer() {
 61+	host := shared.GetEnv("PICO_HOST", "0.0.0.0")
 62+	port := shared.GetEnv("PICO_SSH_PORT", "2222")
 63+	promPort := shared.GetEnv("PICO_PROM_PORT", "9222")
 64+	cfg := NewConfigSite()
 65+	logger := cfg.Logger
 66+	dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
 67+	defer dbpool.Close()
 68+
 69+	handler := NewUploadHandler(
 70+		dbpool,
 71+		cfg,
 72+	)
 73+	cliHandler := &CliHandler{
 74+		Logger: logger,
 75+		DBPool: dbpool,
 76+	}
 77+
 78+	s, err := wish.NewServer(
 79+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
 80+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
 81+		wish.WithPublicKeyAuth(authHandler),
 82+		withProxy(
 83+			cfg,
 84+			handler,
 85+			cliHandler,
 86+			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "pgs-ssh"),
 87+		),
 88+	)
 89+	if err != nil {
 90+		logger.Error(err.Error())
 91+		return
 92+	}
 93+
 94+	done := make(chan os.Signal, 1)
 95+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 96+	logger.Info("starting SSH server on", "host", host, "port", port)
 97+	go func() {
 98+		if err = s.ListenAndServe(); err != nil {
 99+			logger.Error("serve", "err", err.Error())
100+			os.Exit(1)
101+		}
102+	}()
103+
104+	<-done
105+	logger.Info("stopping SSH server")
106+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
107+	defer func() { cancel() }()
108+	if err := s.Shutdown(ctx); err != nil {
109+		logger.Error("shutdown", "err", err.Error())
110+		os.Exit(1)
111+	}
112+}
M tui/account/create.go
+11, -5
 1@@ -8,7 +8,9 @@ import (
 2 	"github.com/charmbracelet/bubbles/spinner"
 3 	input "github.com/charmbracelet/bubbles/textinput"
 4 	tea "github.com/charmbracelet/bubbletea"
 5+	"github.com/charmbracelet/ssh"
 6 	"github.com/picosh/pico/db"
 7+	"github.com/picosh/pico/shared"
 8 	"github.com/picosh/pico/tui/common"
 9 )
10 
11@@ -49,7 +51,7 @@ type CreateModel struct {
12 	Quit bool // true when the user wants to quit the whole program
13 
14 	dbpool    db.DB
15-	publicKey string
16+	publicKey ssh.PublicKey
17 	styles    common.Styles
18 	state     state
19 	newName   string
20@@ -92,7 +94,7 @@ func (m *CreateModel) indexBackward() {
21 }
22 
23 // NewModel returns a new username model in its initial state.
24-func NewCreateModel(styles common.Styles, dbpool db.DB, publicKey string) CreateModel {
25+func NewCreateModel(styles common.Styles, dbpool db.DB, publicKey ssh.PublicKey) CreateModel {
26 	im := input.New()
27 	im.Cursor.Style = styles.Cursor
28 	im.Placeholder = "enter username"
29@@ -116,7 +118,7 @@ func NewCreateModel(styles common.Styles, dbpool db.DB, publicKey string) Create
30 }
31 
32 // Init is the Bubble Tea initialization function.
33-func Init(styles common.Styles, dbpool db.DB, publicKey string) func() (CreateModel, tea.Cmd) {
34+func Init(styles common.Styles, dbpool db.DB, publicKey ssh.PublicKey) func() (CreateModel, tea.Cmd) {
35 	return func() (CreateModel, tea.Cmd) {
36 		m := NewCreateModel(styles, dbpool, publicKey)
37 		return m, InitialCmd()
38@@ -264,8 +266,12 @@ func createAccount(m CreateModel) tea.Cmd {
39 			return NameInvalidMsg{}
40 		}
41 
42-		user, err := m.dbpool.RegisterUser(m.newName, m.publicKey)
43-		fmt.Println(err)
44+		key, err := shared.KeyForKeyText(m.publicKey)
45+		if err != nil {
46+			return errMsg{err}
47+		}
48+
49+		user, err := m.dbpool.RegisterUser(m.newName, key)
50 		if err != nil {
51 			if errors.Is(err, db.ErrNameTaken) {
52 				return NameTakenMsg{}
M tui/cms.go
+8, -14
 1@@ -79,27 +79,16 @@ func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
 2 			logger.Info("no active terminal, skipping")
 3 			return nil, nil
 4 		}
 5-		key, err := shared.KeyText(s)
 6-		if err != nil {
 7-			logger.Error(err.Error())
 8-			return nil, nil
 9-		}
10 
11 		sshUser := s.User()
12-
13 		dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
14-
15-		if err != nil {
16-			logger.Error(err.Error())
17-		}
18-
19 		renderer := lipgloss.NewRenderer(s)
20 		renderer.SetOutput(common.OutputFromSession(s))
21 		styles := common.DefaultStyles(renderer)
22 
23 		m := model{
24 			cfg:        cfg,
25-			publicKey:  key,
26+			publicKey:  s.PublicKey(),
27 			dbpool:     dbpool,
28 			sshUser:    sshUser,
29 			status:     statusInit,
30@@ -129,7 +118,7 @@ func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
31 // Just a generic tea.Model to demo terminal information of ssh.
32 type model struct {
33 	cfg             *shared.ConfigSite
34-	publicKey       string
35+	publicKey       ssh.PublicKey
36 	dbpool          db.DB
37 	user            *db.User
38 	plusFeatureFlag *db.FeatureFlag
39@@ -160,7 +149,12 @@ func (m model) findUser() (*db.User, error) {
40 		return nil, nil
41 	}
42 
43-	user, err := m.dbpool.FindUserForKey(m.sshUser, m.publicKey)
44+	key, err := shared.KeyForKeyText(m.publicKey)
45+	if err != nil {
46+		return nil, err
47+	}
48+
49+	user, err = m.dbpool.FindUserForKey(m.sshUser, key)
50 
51 	if err != nil {
52 		logger.Error("no user found for public key", "err", err.Error())
M tui/createkey/create.go
+8, -27
 1@@ -8,6 +8,7 @@ import (
 2 	input "github.com/charmbracelet/bubbles/textinput"
 3 	tea "github.com/charmbracelet/bubbletea"
 4 	"github.com/picosh/pico/db"
 5+	"github.com/picosh/pico/shared"
 6 	"github.com/picosh/pico/tui/common"
 7 	"golang.org/x/crypto/ssh"
 8 )
 9@@ -245,38 +246,18 @@ func spinnerView(m Model) string {
10 	return m.spinner.View() + " Submitting..."
11 }
12 
13-func IsPublicKeyValid(key string) bool {
14-	if len(key) == 0 {
15-		return false
16-	}
17-
18-	_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
19-	return err == nil
20-}
21-
22-func sanitizeKey(key string) string {
23-	// comments are removed when using our ssh app so
24-	// we need to be sure to remove them from the public key
25-	parts := strings.Split(key, " ")
26-	keep := []string{}
27-	for i, part := range parts {
28-		if i == 2 {
29-			break
30-		}
31-		keep = append(keep, strings.Trim(part, " "))
32-	}
33-
34-	return strings.Join(keep, " ")
35-}
36-
37 func addPublicKey(m Model) tea.Cmd {
38 	return func() tea.Msg {
39-		if !IsPublicKeyValid(m.newKey) {
40+		pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(m.newKey))
41+		if err != nil {
42 			return KeyInvalidMsg{}
43 		}
44 
45-		key := sanitizeKey(m.newKey)
46-		err := m.dbpool.LinkUserKey(m.user.ID, key, nil)
47+		key, err := shared.KeyForKeyText(pk)
48+		if err != nil {
49+			return KeyInvalidMsg{}
50+		}
51+		err = m.dbpool.LinkUserKey(m.user.ID, key, nil)
52 		if err != nil {
53 			if errors.Is(err, db.ErrPublicKeyTaken) {
54 				return KeyTakenMsg{}
M tui/keys/keyview.go
+25, -18
 1@@ -72,14 +72,16 @@ func (f fingerprint) state(s keyState, styles common.Styles) string {
 2 }
 3 
 4 type styledKey struct {
 5-	styles      common.Styles
 6-	date        string
 7-	fingerprint fingerprint
 8-	gutter      string
 9-	keyLabel    string
10-	dateLabel   string
11-	dateVal     string
12-	note        string
13+	styles       common.Styles
14+	date         string
15+	fingerprint  fingerprint
16+	gutter       string
17+	keyLabel     string
18+	dateLabel    string
19+	commentLabel string
20+	commentVal   string
21+	dateVal      string
22+	note         string
23 }
24 
25 func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool) styledKey {
26@@ -96,14 +98,16 @@ func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool
27 
28 	// Default state
29 	return styledKey{
30-		styles:      styles,
31-		date:        date,
32-		fingerprint: fingerprint{fp},
33-		gutter:      " ",
34-		keyLabel:    "Key:",
35-		dateLabel:   "Added:",
36-		dateVal:     styles.LabelDim.Render(date),
37-		note:        note,
38+		styles:       styles,
39+		date:         date,
40+		fingerprint:  fingerprint{fp},
41+		gutter:       " ",
42+		keyLabel:     "Key:",
43+		dateLabel:    "Added:",
44+		commentLabel: "Comment:",
45+		commentVal:   key.Name,
46+		dateVal:      styles.LabelDim.Render(date),
47+		note:         note,
48 	}
49 }
50 
51@@ -112,6 +116,7 @@ func (k *styledKey) selected() {
52 	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
53 	k.keyLabel = k.styles.Label.Render("Key:")
54 	k.dateLabel = k.styles.Label.Render("Added:")
55+	k.commentLabel = k.styles.Label.Render("Comment:")
56 }
57 
58 // Deleting state.
59@@ -119,6 +124,7 @@ func (k *styledKey) deleting() {
60 	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
61 	k.keyLabel = k.styles.Delete.Render("Key:")
62 	k.dateLabel = k.styles.Delete.Render("Added:")
63+	k.commentLabel = k.styles.Delete.Render("Comment:")
64 	k.dateVal = k.styles.DeleteDim.Render(k.date)
65 }
66 
67@@ -130,8 +136,9 @@ func (k styledKey) render(state keyState) string {
68 		k.deleting()
69 	}
70 	return fmt.Sprintf(
71-		"%s %s %s\n%s %s %s %s\n\n",
72+		"%s %s %s\n%s %s %s\n%s %s %s %s\n\n",
73 		k.gutter, k.keyLabel, k.fingerprint.state(state, k.styles),
74-		k.gutter, k.dateLabel, k.dateVal, k.note,
75+		k.gutter, k.dateLabel, k.dateVal,
76+		k.gutter, k.commentLabel, k.commentVal, k.note,
77 	)
78 }