- 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
Makefile
+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
+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
+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+}
+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+}
+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`
+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:
+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
+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
+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
+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 }
+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+}
+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+}
+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+}
+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+}
+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{}
+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())
+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{}
+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 }