Commit 1d500f9

Eric Bower  ·  2025-12-25 23:08:50 -0500 EST
parent 9658c7d
refactor(pipe): cli code organization

Use a struct with methods to abstract cli middleware code into discrete
functions
1 files changed,  +611, -568
+611, -568
......@@ -21,138 +21,7 @@ import (
2121 gossh "golang.org/x/crypto/ssh"
2222 )
2323
24-func flagSet(cmdName string, sesh *pssh.SSHServerConnSession) *flag.FlagSet {
25- cmd := flag.NewFlagSet(cmdName, flag.ContinueOnError)
26- cmd.SetOutput(sesh)
27- cmd.Usage = func() {
28- _, _ = fmt.Fprintf(cmd.Output(), "Usage: %s <topic> [args...]\nArgs:\n", cmdName)
29- cmd.PrintDefaults()
30- }
31- return cmd
32-}
33-
34-func flagCheck(cmd *flag.FlagSet, posArg string, cmdArgs []string) bool {
35- err := cmd.Parse(cmdArgs)
36-
37- if err != nil || posArg == "help" {
38- if posArg == "help" {
39- cmd.Usage()
40- }
41- return false
42- }
43- return true
44-}
45-
46-func NewTabWriter(out io.Writer) *tabwriter.Writer {
47- return tabwriter.NewWriter(out, 0, 0, 1, ' ', tabwriter.TabIndent)
48-}
49-
50-// scope topic to user by prefixing name.
51-func toTopic(userName, topic string) string {
52- if strings.HasPrefix(topic, userName+"/") {
53- return topic
54- }
55- return fmt.Sprintf("%s/%s", userName, topic)
56-}
57-
58-func toPublicTopic(topic string) string {
59- if strings.HasPrefix(topic, "public/") {
60- return topic
61- }
62- return fmt.Sprintf("public/%s", topic)
63-}
64-
65-func clientInfo(clients []*psub.Client, isAdmin bool, clientType string) string {
66- if len(clients) == 0 {
67- return ""
68- }
69-
70- outputData := fmt.Sprintf(" %s:\r\n", clientType)
71-
72- for _, client := range clients {
73- if strings.HasPrefix(client.ID, "admin-") && !isAdmin {
74- continue
75- }
76-
77- outputData += fmt.Sprintf(" - %s\r\n", client.ID)
78- }
79-
80- return outputData
81-}
82-
83-var helpStr = func(sshCmd string) string {
84- data := fmt.Sprintf(`Command: ssh %s <help | ls | pub | sub | pipe> <topic> [-h | args...]
85-
86-The simplest authenticated pubsub system. Send messages through
87-user-defined topics. Topics are private to the authenticated
88-ssh user. The default pubsub model is multicast with bidirectional
89-blocking, meaning a publisher ("pub") will send its message to all
90-subscribers ("sub"). Further, both "pub" and "sub" will wait for
91-at least one event to be sent or received. Pipe ("pipe") allows
92-for bidirectional messages to be sent between any clients connected
93-to a pipe.
94-
95-Think of these different commands in terms of the direction the
96-data is being sent:
97-
98-- pub => writes to client
99-- sub => reads from client
100-- pipe => read and write between clients
101-`, sshCmd)
102-
103- data = strings.ReplaceAll(data, "\n", "\r\n")
104-
105- return data
106-}
107-
108-type CliHandler struct {
109- DBPool db.DB
110- Logger *slog.Logger
111- PubSub psub.PubSub
112- Cfg *shared.ConfigSite
113- Waiters *syncmap.Map[string, []string]
114- Access *syncmap.Map[string, []string]
115-}
116-
117-func (h *CliHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
118- return h.Logger
119-}
120-
121-func toSshCmd(cfg *shared.ConfigSite) string {
122- port := ""
123- if cfg.PortOverride != "22" {
124- port = fmt.Sprintf("-p %s ", cfg.PortOverride)
125- }
126- return fmt.Sprintf("%s%s", port, cfg.Domain)
127-}
128-
129-// parseArgList parses a comma separated list of arguments.
130-func parseArgList(arg string) []string {
131- argList := strings.Split(arg, ",")
132- for i, acc := range argList {
133- argList[i] = strings.TrimSpace(acc)
134- }
135- return argList
136-}
137-
138-// checkAccess checks if the user has access to a topic based on an access list.
139-func checkAccess(accessList []string, userName string, sesh *pssh.SSHServerConnSession) bool {
140- for _, acc := range accessList {
141- if acc == userName {
142- return true
143- }
144-
145- if key := sesh.PublicKey(); key != nil && acc == gossh.FingerprintSHA256(key) {
146- return true
147- }
148- }
149-
150- return false
151-}
152-
15324 func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
154- pubsub := handler.PubSub
155-
15625 return func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
15726 return func(sesh *pssh.SSHServerConnSession) error {
15827 ctx := sesh.Context()
......@@ -160,22 +29,19 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
16029 user := pssh.GetUser(sesh)
16130
16231 args := sesh.Command()
163-
16432 if len(args) == 0 {
165- _, _ = fmt.Fprintln(sesh, helpStr(toSshCmd(handler.Cfg)))
33+ help(handler.Cfg, sesh)
16634 return next(sesh)
16735 }
16836
16937 userName := "public"
170-
17138 userNameAddition := ""
172-
39+ uuidStr := uuid.NewString()
17340 isAdmin := false
174- impersonate := false
17541 if user != nil {
17642 isAdmin = handler.DBPool.HasFeatureByUser(user.ID, "admin")
17743 if isAdmin && strings.HasPrefix(sesh.User(), "admin__") {
178- impersonate = true
44+ uuidStr = fmt.Sprintf("admin-%s", uuidStr)
17945 }
18046
18147 userName = user.Name
......@@ -205,97 +71,25 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
20571 }
20672 }()
20773
74+ cliCmd := &CliCmd{
75+ sesh: sesh,
76+ args: args,
77+ userName: userName,
78+ isAdmin: isAdmin,
79+ pipeCtx: pipeCtx,
80+ cancel: cancel,
81+ }
82+
20883 cmd := strings.TrimSpace(args[0])
209- if cmd == "help" {
210- _, _ = fmt.Fprintln(sesh, helpStr(toSshCmd(handler.Cfg)))
84+ switch cmd {
85+ case "help":
86+ help(handler.Cfg, sesh)
21187 return next(sesh)
212- } else if cmd == "ls" {
213- if userName == "public" {
214- err := fmt.Errorf("access denied")
88+ case "ls":
89+ err := handler.ls(cliCmd)
90+ if err != nil {
21591 sesh.Fatal(err)
216- return err
217- }
218-
219- topicFilter := fmt.Sprintf("%s/", userName)
220- if isAdmin {
221- topicFilter = ""
222- if len(args) > 1 {
223- topicFilter = args[1]
224- }
225- }
226-
227- var channels []*psub.Channel
228- waitingChannels := map[string][]string{}
229-
230- for topic, channel := range pubsub.GetChannels() {
231- if strings.HasPrefix(topic, topicFilter) {
232- channels = append(channels, channel)
233- }
23492 }
235-
236- for channel, clients := range handler.Waiters.Range {
237- if strings.HasPrefix(channel, topicFilter) {
238- waitingChannels[channel] = clients
239- }
240- }
241-
242- if len(channels) == 0 && len(waitingChannels) == 0 {
243- _, _ = fmt.Fprintln(sesh, "no pubsub channels found")
244- } else {
245- var outputData string
246- if len(channels) > 0 || len(waitingChannels) > 0 {
247- outputData += "Channel Information\r\n"
248- for _, channel := range channels {
249- extraData := ""
250-
251- if accessList, ok := handler.Access.Load(channel.Topic); ok && len(accessList) > 0 {
252- extraData += fmt.Sprintf(" (Access List: %s)", strings.Join(accessList, ", "))
253- }
254-
255- outputData += fmt.Sprintf("- %s:%s\r\n", channel.Topic, extraData)
256- outputData += " Clients:\r\n"
257-
258- var pubs []*psub.Client
259- var subs []*psub.Client
260- var pipes []*psub.Client
261-
262- for _, client := range channel.GetClients() {
263- switch client.Direction {
264- case psub.ChannelDirectionInput:
265- pubs = append(pubs, client)
266- case psub.ChannelDirectionOutput:
267- subs = append(subs, client)
268- case psub.ChannelDirectionInputOutput:
269- pipes = append(pipes, client)
270- }
271- }
272- outputData += clientInfo(pubs, isAdmin, "Pubs")
273- outputData += clientInfo(subs, isAdmin, "Subs")
274- outputData += clientInfo(pipes, isAdmin, "Pipes")
275- }
276-
277- for waitingChannel, channelPubs := range waitingChannels {
278- extraData := ""
279-
280- if accessList, ok := handler.Access.Load(waitingChannel); ok && len(accessList) > 0 {
281- extraData += fmt.Sprintf(" (Access List: %s)", strings.Join(accessList, ", "))
282- }
283-
284- outputData += fmt.Sprintf("- %s:%s\r\n", waitingChannel, extraData)
285- outputData += " Clients:\r\n"
286- outputData += fmt.Sprintf(" %s:\r\n", "Waiting Pubs")
287- for _, client := range channelPubs {
288- if strings.HasPrefix(client, "admin-") && !isAdmin {
289- continue
290- }
291- outputData += fmt.Sprintf(" - %s\r\n", client)
292- }
293- }
294- }
295-
296- _, _ = sesh.Write([]byte(outputData))
297- }
298-
29993 return next(sesh)
30094 }
30195
......@@ -305,6 +99,8 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
30599 topic = strings.TrimSpace(args[1])
306100 cmdArgs = args[2:]
307101 }
102+ // sub commands after this line expect clipped args
103+ cliCmd.args = cmdArgs
308104
309105 logger.Info(
310106 "pubsub middleware detected command",
......@@ -314,421 +110,668 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
314110 "cmdArgs", cmdArgs,
315111 )
316112
317- uuidStr := uuid.NewString()
318- if impersonate {
319- uuidStr = fmt.Sprintf("admin-%s", uuidStr)
113+ clientID := fmt.Sprintf(
114+ "%s (%s%s@%s)",
115+ uuidStr,
116+ userName,
117+ userNameAddition,
118+ sesh.RemoteAddr().String(),
119+ )
120+
121+ switch cmd {
122+ case "pub":
123+ err := handler.pub(cliCmd, topic, clientID)
124+ if err != nil {
125+ sesh.Fatal(err)
126+ }
127+ case "sub":
128+ err := handler.sub(cliCmd, topic, clientID)
129+ if err != nil {
130+ sesh.Fatal(err)
131+ }
132+ case "pipe":
133+ err := handler.pipe(cliCmd, topic, clientID)
134+ if err != nil {
135+ sesh.Fatal(err)
136+ }
320137 }
321138
322- clientID := fmt.Sprintf("%s (%s%s@%s)", uuidStr, userName, userNameAddition, sesh.RemoteAddr().String())
139+ return next(sesh)
140+ }
141+ }
142+}
323143
324- var err error
144+type CliHandler struct {
145+ DBPool db.DB
146+ Logger *slog.Logger
147+ PubSub psub.PubSub
148+ Cfg *shared.ConfigSite
149+ Waiters *syncmap.Map[string, []string]
150+ Access *syncmap.Map[string, []string]
151+}
325152
326- if cmd == "pub" {
327- pubCmd := flagSet("pub", sesh)
328- access := pubCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic")
329- empty := pubCmd.Bool("e", false, "Send an empty message to subs")
330- public := pubCmd.Bool("p", false, "Publish message to public topic")
331- block := pubCmd.Bool("b", true, "Block writes until a subscriber is available")
332- timeout := pubCmd.Duration("t", 30*24*time.Hour, "Timeout as a Go duration to block for a subscriber to be available. Valid time units are 'ns', 'us' (or 'µs'), 'ms', 's', 'm', 'h'. Default is 30 days.")
333- clean := pubCmd.Bool("c", false, "Don't send status messages")
153+func (h *CliHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
154+ return h.Logger
155+}
334156
335- if !flagCheck(pubCmd, topic, cmdArgs) {
336- return err
337- }
157+type CliCmd struct {
158+ sesh *pssh.SSHServerConnSession
159+ args []string
160+ userName string
161+ isAdmin bool
162+ pipeCtx context.Context
163+ cancel context.CancelFunc
164+}
338165
339- if pubCmd.NArg() == 1 && topic == "" {
340- topic = pubCmd.Arg(0)
341- }
166+func help(cfg *shared.ConfigSite, sesh *pssh.SSHServerConnSession) {
167+ data := fmt.Sprintf(`Command: ssh %s <help | ls | pub | sub | pipe> <topic> [-h | args...]
342168
343- logger.Info(
344- "flags parsed",
345- "cmd", cmd,
346- "empty", *empty,
347- "public", *public,
348- "block", *block,
349- "timeout", *timeout,
350- "topic", topic,
351- "access", *access,
352- "clean", *clean,
353- )
354-
355- var accessList []string
356-
357- if *access != "" {
358- accessList = parseArgList(*access)
359- }
169+The simplest authenticated pubsub system. Send messages through
170+user-defined topics. Topics are private to the authenticated
171+ssh user. The default pubsub model is multicast with bidirectional
172+blocking, meaning a publisher ("pub") will send its message to all
173+subscribers ("sub"). Further, both "pub" and "sub" will wait for
174+at least one event to be sent or received. Pipe ("pipe") allows
175+for bidirectional messages to be sent between any clients connected
176+to a pipe.
360177
361- var rw io.ReadWriter
362- if *empty {
363- rw = bytes.NewBuffer(make([]byte, 1))
364- } else {
365- rw = sesh
366- }
178+Think of these different commands in terms of the direction the
179+data is being sent:
367180
368- if topic == "" {
369- topic = uuid.NewString()
370- }
181+- pub => writes to client
182+- sub => reads from client
183+- pipe => read and write between clients
184+`, toSshCmd(cfg))
371185
372- var withoutUser string
373- var name string
374- msgFlag := ""
186+ data = strings.ReplaceAll(data, "\n", "\r\n")
187+ _, _ = fmt.Fprintln(sesh, data)
188+}
375189
376- if isAdmin && strings.HasPrefix(topic, "/") {
377- name = strings.TrimPrefix(topic, "/")
378- } else {
379- name = toTopic(userName, topic)
380- if *public {
381- name = toPublicTopic(topic)
382- msgFlag = "-p "
383- withoutUser = name
384- } else {
385- withoutUser = topic
386- }
387- }
190+func (handler *CliHandler) ls(cmd *CliCmd) error {
191+ if cmd.userName == "public" {
192+ err := fmt.Errorf("access denied")
193+ return err
194+ }
388195
389- var accessListCreator bool
196+ topicFilter := fmt.Sprintf("%s/", cmd.userName)
197+ if cmd.isAdmin {
198+ topicFilter = ""
199+ if len(cmd.args) > 1 {
200+ topicFilter = cmd.args[1]
201+ }
202+ }
390203
391- _, loaded := handler.Access.LoadOrStore(name, accessList)
392- if !loaded {
393- defer func() {
394- handler.Access.Delete(name)
395- }()
204+ var channels []*psub.Channel
205+ waitingChannels := map[string][]string{}
396206
397- accessListCreator = true
398- }
207+ for topic, channel := range handler.PubSub.GetChannels() {
208+ if strings.HasPrefix(topic, topicFilter) {
209+ channels = append(channels, channel)
210+ }
211+ }
399212
400- if accessList, ok := handler.Access.Load(withoutUser); ok && len(accessList) > 0 && !isAdmin {
401- if checkAccess(accessList, userName, sesh) || accessListCreator {
402- name = withoutUser
403- } else if !*public {
404- name = toTopic(userName, withoutUser)
405- } else {
406- topic = uuid.NewString()
407- name = toPublicTopic(topic)
213+ for channel, clients := range handler.Waiters.Range {
214+ if strings.HasPrefix(channel, topicFilter) {
215+ waitingChannels[channel] = clients
216+ }
217+ }
218+
219+ if len(channels) == 0 && len(waitingChannels) == 0 {
220+ _, _ = fmt.Fprintln(cmd.sesh, "no pubsub channels found")
221+ } else {
222+ var outputData string
223+ if len(channels) > 0 || len(waitingChannels) > 0 {
224+ outputData += "Channel Information\r\n"
225+ for _, channel := range channels {
226+ extraData := ""
227+
228+ if accessList, ok := handler.Access.Load(channel.Topic); ok && len(accessList) > 0 {
229+ extraData += fmt.Sprintf(" (Access List: %s)", strings.Join(accessList, ", "))
230+ }
231+
232+ outputData += fmt.Sprintf("- %s:%s\r\n", channel.Topic, extraData)
233+
234+ var pubs []*psub.Client
235+ var subs []*psub.Client
236+ var pipes []*psub.Client
237+
238+ for _, client := range channel.GetClients() {
239+ switch client.Direction {
240+ case psub.ChannelDirectionInput:
241+ pubs = append(pubs, client)
242+ case psub.ChannelDirectionOutput:
243+ subs = append(subs, client)
244+ case psub.ChannelDirectionInputOutput:
245+ pipes = append(pipes, client)
408246 }
409247 }
248+ outputData += clientInfo(pubs, cmd.isAdmin, "Pubs")
249+ outputData += clientInfo(subs, cmd.isAdmin, "Subs")
250+ outputData += clientInfo(pipes, cmd.isAdmin, "Pipes")
251+ }
410252
411- if !*clean {
412- fmtTopic := topic
413- if *access != "" {
414- fmtTopic = fmt.Sprintf("%s/%s", userName, topic)
415- }
253+ for waitingChannel, channelPubs := range waitingChannels {
254+ extraData := ""
416255
417- _, _ = fmt.Fprintf(
418- sesh,
419- "subscribe to this channel:\n ssh %s sub %s%s\n",
420- toSshCmd(handler.Cfg),
421- msgFlag,
422- fmtTopic,
423- )
256+ if accessList, ok := handler.Access.Load(waitingChannel); ok && len(accessList) > 0 {
257+ extraData += fmt.Sprintf(" (Access List: %s)", strings.Join(accessList, ", "))
424258 }
425259
426- if *block {
427- count := 0
428- for topic, channel := range pubsub.GetChannels() {
429- if topic == name {
430- for _, client := range channel.GetClients() {
431- if client.Direction == psub.ChannelDirectionOutput || client.Direction == psub.ChannelDirectionInputOutput {
432- count++
433- }
434- }
435- break
436- }
260+ outputData += fmt.Sprintf("- %s:%s\r\n", waitingChannel, extraData)
261+ outputData += fmt.Sprintf(" %s:\r\n", "Waiting Pubs")
262+ for _, client := range channelPubs {
263+ if strings.HasPrefix(client, "admin-") && !cmd.isAdmin {
264+ continue
437265 }
266+ outputData += fmt.Sprintf(" - %s\r\n", client)
267+ }
268+ }
269+ }
438270
439- tt := *timeout
440- if count == 0 {
441- currentWaiters, _ := handler.Waiters.LoadOrStore(name, nil)
442- handler.Waiters.Store(name, append(currentWaiters, clientID))
271+ _, _ = cmd.sesh.Write([]byte(outputData))
272+ }
443273
444- termMsg := "no subs found ... waiting"
445- if tt > 0 {
446- termMsg += " " + tt.String()
447- }
274+ return nil
275+}
448276
449- if !*clean {
450- _, _ = fmt.Fprintln(sesh, termMsg)
451- }
277+func (handler *CliHandler) pub(cmd *CliCmd, topic string, clientID string) error {
278+ pubCmd := flagSet("pub", cmd.sesh)
279+ access := pubCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic")
280+ empty := pubCmd.Bool("e", false, "Send an empty message to subs")
281+ public := pubCmd.Bool("p", false, "Publish message to public topic")
282+ block := pubCmd.Bool("b", true, "Block writes until a subscriber is available")
283+ timeout := pubCmd.Duration("t", 30*24*time.Hour, "Timeout as a Go duration to block for a subscriber to be available. Valid time units are 'ns', 'us' (or 'µs'), 'ms', 's', 'm', 'h'. Default is 30 days.")
284+ clean := pubCmd.Bool("c", false, "Don't send status messages")
285+
286+ if !flagCheck(pubCmd, topic, cmd.args) {
287+ return fmt.Errorf("invalid cmd args")
288+ }
452289
453- ready := make(chan struct{})
454-
455- go func() {
456- for {
457- select {
458- case <-pipeCtx.Done():
459- cancel()
460- return
461- case <-time.After(1 * time.Millisecond):
462- count := 0
463- for topic, channel := range pubsub.GetChannels() {
464- if topic == name {
465- for _, client := range channel.GetClients() {
466- if client.Direction == psub.ChannelDirectionOutput || client.Direction == psub.ChannelDirectionInputOutput {
467- count++
468- }
469- }
470- break
471- }
472- }
290+ if pubCmd.NArg() == 1 && topic == "" {
291+ topic = pubCmd.Arg(0)
292+ }
473293
474- if count > 0 {
475- close(ready)
476- return
477- }
478- }
479- }
480- }()
481-
482- select {
483- case <-ready:
484- case <-pipeCtx.Done():
485- case <-time.After(tt):
486- cancel()
487-
488- if !*clean {
489- sesh.Fatal(fmt.Errorf("timeout reached, exiting"))
490- } else {
491- err = sesh.Exit(1)
492- if err != nil {
493- logger.Error("error exiting session", "err", err)
494- }
294+ handler.Logger.Info(
295+ "flags parsed",
296+ "cmd", "pub",
297+ "empty", *empty,
298+ "public", *public,
299+ "block", *block,
300+ "timeout", *timeout,
301+ "topic", topic,
302+ "access", *access,
303+ "clean", *clean,
304+ )
305+
306+ var accessList []string
307+
308+ if *access != "" {
309+ accessList = parseArgList(*access)
310+ }
495311
496- _ = sesh.Close()
497- }
498- }
312+ var rw io.ReadWriter
313+ if *empty {
314+ rw = bytes.NewBuffer(make([]byte, 1))
315+ } else {
316+ rw = cmd.sesh
317+ }
318+
319+ if topic == "" {
320+ topic = uuid.NewString()
321+ }
322+
323+ var withoutUser string
324+ var name string
325+ msgFlag := ""
326+
327+ if cmd.isAdmin && strings.HasPrefix(topic, "/") {
328+ name = strings.TrimPrefix(topic, "/")
329+ } else {
330+ name = toTopic(cmd.userName, topic)
331+ if *public {
332+ name = toPublicTopic(topic)
333+ msgFlag = "-p "
334+ withoutUser = name
335+ } else {
336+ withoutUser = topic
337+ }
338+ }
339+
340+ var accessListCreator bool
341+ _, loaded := handler.Access.LoadOrStore(name, accessList)
342+ if !loaded {
343+ defer func() {
344+ handler.Access.Delete(name)
345+ }()
346+
347+ accessListCreator = true
348+ }
499349
500- newWaiters, _ := handler.Waiters.LoadOrStore(name, nil)
501- newWaiters = slices.DeleteFunc(newWaiters, func(cl string) bool {
502- return cl == clientID
503- })
504- handler.Waiters.Store(name, newWaiters)
350+ if accessList, ok := handler.Access.Load(withoutUser); ok && len(accessList) > 0 && !cmd.isAdmin {
351+ if checkAccess(accessList, cmd.userName, cmd.sesh) || accessListCreator {
352+ name = withoutUser
353+ } else if !*public {
354+ name = toTopic(cmd.userName, withoutUser)
355+ } else {
356+ topic = uuid.NewString()
357+ name = toPublicTopic(topic)
358+ }
359+ }
505360
506- var toDelete []string
361+ if !*clean {
362+ fmtTopic := topic
363+ if *access != "" {
364+ fmtTopic = fmt.Sprintf("%s/%s", cmd.userName, topic)
365+ }
507366
508- for channel, clients := range handler.Waiters.Range {
509- if len(clients) == 0 {
510- toDelete = append(toDelete, channel)
367+ _, _ = fmt.Fprintf(
368+ cmd.sesh,
369+ "subscribe to this channel:\n ssh %s sub %s%s\n",
370+ toSshCmd(handler.Cfg),
371+ msgFlag,
372+ fmtTopic,
373+ )
374+ }
375+
376+ if *block {
377+ count := 0
378+ for topic, channel := range handler.PubSub.GetChannels() {
379+ if topic == name {
380+ for _, client := range channel.GetClients() {
381+ if client.Direction == psub.ChannelDirectionOutput || client.Direction == psub.ChannelDirectionInputOutput {
382+ count++
383+ }
384+ }
385+ break
386+ }
387+ }
388+
389+ tt := *timeout
390+ if count == 0 {
391+ currentWaiters, _ := handler.Waiters.LoadOrStore(name, nil)
392+ handler.Waiters.Store(name, append(currentWaiters, clientID))
393+
394+ termMsg := "no subs found ... waiting"
395+ if tt > 0 {
396+ termMsg += " " + tt.String()
397+ }
398+
399+ if !*clean {
400+ _, _ = fmt.Fprintln(cmd.sesh, termMsg)
401+ }
402+
403+ ready := make(chan struct{})
404+
405+ go func() {
406+ for {
407+ select {
408+ case <-cmd.pipeCtx.Done():
409+ cmd.cancel()
410+ return
411+ case <-time.After(1 * time.Millisecond):
412+ count := 0
413+ for topic, channel := range handler.PubSub.GetChannels() {
414+ if topic == name {
415+ for _, client := range channel.GetClients() {
416+ if client.Direction == psub.ChannelDirectionOutput || client.Direction == psub.ChannelDirectionInputOutput {
417+ count++
418+ }
419+ }
420+ break
511421 }
512422 }
513423
514- for _, channel := range toDelete {
515- handler.Waiters.Delete(channel)
424+ if count > 0 {
425+ close(ready)
426+ return
516427 }
517428 }
518429 }
430+ }()
431+
432+ select {
433+ case <-ready:
434+ case <-cmd.pipeCtx.Done():
435+ case <-time.After(tt):
436+ cmd.cancel()
519437
520438 if !*clean {
521- _, _ = fmt.Fprintln(sesh, "sending msg ...")
439+ return fmt.Errorf("timeout reached, exiting")
440+ } else {
441+ err := cmd.sesh.Exit(1)
442+ if err != nil {
443+ handler.Logger.Error("error exiting session", "err", err)
444+ }
445+
446+ _ = cmd.sesh.Close()
522447 }
448+ }
523449
524- err = pubsub.Pub(
525- pipeCtx,
526- clientID,
527- rw,
528- []*psub.Channel{
529- psub.NewChannel(name),
530- },
531- *block,
532- )
450+ newWaiters, _ := handler.Waiters.LoadOrStore(name, nil)
451+ newWaiters = slices.DeleteFunc(newWaiters, func(cl string) bool {
452+ return cl == clientID
453+ })
454+ handler.Waiters.Store(name, newWaiters)
533455
534- if !*clean {
535- _, _ = fmt.Fprintln(sesh, "msg sent!")
536- }
456+ var toDelete []string
537457
538- if err != nil && !*clean {
539- _, _ = fmt.Fprintln(sesh.Stderr(), err)
540- }
541- } else if cmd == "sub" {
542- subCmd := flagSet("sub", sesh)
543- access := subCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic")
544- public := subCmd.Bool("p", false, "Subscribe to a public topic")
545- keepAlive := subCmd.Bool("k", false, "Keep the subscription alive even after the publisher has died")
546- clean := subCmd.Bool("c", false, "Don't send status messages")
547-
548- if !flagCheck(subCmd, topic, cmdArgs) {
549- return err
458+ for channel, clients := range handler.Waiters.Range {
459+ if len(clients) == 0 {
460+ toDelete = append(toDelete, channel)
550461 }
462+ }
551463
552- if subCmd.NArg() == 1 && topic == "" {
553- topic = subCmd.Arg(0)
554- }
464+ for _, channel := range toDelete {
465+ handler.Waiters.Delete(channel)
466+ }
467+ }
468+ }
555469
556- logger.Info(
557- "flags parsed",
558- "cmd", cmd,
559- "public", *public,
560- "keepAlive", *keepAlive,
561- "topic", topic,
562- "clean", *clean,
563- "access", *access,
564- )
470+ if !*clean {
471+ _, _ = fmt.Fprintln(cmd.sesh, "sending msg ...")
472+ }
565473
566- var accessList []string
474+ err := handler.PubSub.Pub(
475+ cmd.pipeCtx,
476+ clientID,
477+ rw,
478+ []*psub.Channel{
479+ psub.NewChannel(name),
480+ },
481+ *block,
482+ )
483+
484+ if !*clean {
485+ _, _ = fmt.Fprintln(cmd.sesh, "msg sent!")
486+ }
567487
568- if *access != "" {
569- accessList = parseArgList(*access)
570- }
488+ if err != nil && !*clean {
489+ return err
490+ }
571491
572- var withoutUser string
573- var name string
492+ return nil
493+}
574494
575- if isAdmin && strings.HasPrefix(topic, "/") {
576- name = strings.TrimPrefix(topic, "/")
577- } else {
578- name = toTopic(userName, topic)
579- if *public {
580- name = toPublicTopic(topic)
581- withoutUser = name
582- } else {
583- withoutUser = topic
584- }
585- }
495+func (handler *CliHandler) sub(cmd *CliCmd, topic string, clientID string) error {
496+ subCmd := flagSet("sub", cmd.sesh)
497+ access := subCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic")
498+ public := subCmd.Bool("p", false, "Subscribe to a public topic")
499+ keepAlive := subCmd.Bool("k", false, "Keep the subscription alive even after the publisher has died")
500+ clean := subCmd.Bool("c", false, "Don't send status messages")
586501
587- var accessListCreator bool
502+ if !flagCheck(subCmd, topic, cmd.args) {
503+ return fmt.Errorf("invalid cmd args")
504+ }
588505
589- _, loaded := handler.Access.LoadOrStore(name, accessList)
590- if !loaded {
591- defer func() {
592- handler.Access.Delete(name)
593- }()
506+ if subCmd.NArg() == 1 && topic == "" {
507+ topic = subCmd.Arg(0)
508+ }
594509
595- accessListCreator = true
596- }
510+ handler.Logger.Info(
511+ "flags parsed",
512+ "cmd", cmd,
513+ "public", *public,
514+ "keepAlive", *keepAlive,
515+ "topic", topic,
516+ "clean", *clean,
517+ "access", *access,
518+ )
597519
598- if accessList, ok := handler.Access.Load(withoutUser); ok && len(accessList) > 0 && !isAdmin {
599- if checkAccess(accessList, userName, sesh) || accessListCreator {
600- name = withoutUser
601- } else if !*public {
602- name = toTopic(userName, withoutUser)
603- } else {
604- _, _ = fmt.Fprintln(sesh.Stderr(), "access denied")
605- return err
606- }
607- }
520+ var accessList []string
608521
609- err = pubsub.Sub(
610- pipeCtx,
611- clientID,
612- sesh,
613- []*psub.Channel{
614- psub.NewChannel(name),
615- },
616- *keepAlive,
617- )
618-
619- if err != nil && !*clean {
620- _, _ = fmt.Fprintln(sesh.Stderr(), err)
621- }
622- } else if cmd == "pipe" {
623- pipeCmd := flagSet("pipe", sesh)
624- access := pipeCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic")
625- public := pipeCmd.Bool("p", false, "Pipe to a public topic")
626- replay := pipeCmd.Bool("r", false, "Replay messages to the client that sent it")
627- clean := pipeCmd.Bool("c", false, "Don't send status messages")
628-
629- if !flagCheck(pipeCmd, topic, cmdArgs) {
630- return err
631- }
522+ if *access != "" {
523+ accessList = parseArgList(*access)
524+ }
632525
633- if pipeCmd.NArg() == 1 && topic == "" {
634- topic = pipeCmd.Arg(0)
635- }
526+ var withoutUser string
527+ var name string
528+
529+ if cmd.isAdmin && strings.HasPrefix(topic, "/") {
530+ name = strings.TrimPrefix(topic, "/")
531+ } else {
532+ name = toTopic(cmd.userName, topic)
533+ if *public {
534+ name = toPublicTopic(topic)
535+ withoutUser = name
536+ } else {
537+ withoutUser = topic
538+ }
539+ }
636540
637- logger.Info(
638- "flags parsed",
639- "cmd", cmd,
640- "public", *public,
641- "replay", *replay,
642- "topic", topic,
643- "access", *access,
644- "clean", *clean,
645- )
541+ var accessListCreator bool
646542
647- var accessList []string
543+ _, loaded := handler.Access.LoadOrStore(name, accessList)
544+ if !loaded {
545+ defer func() {
546+ handler.Access.Delete(name)
547+ }()
548+ accessListCreator = true
549+ }
648550
649- if *access != "" {
650- accessList = parseArgList(*access)
651- }
551+ if accessList, ok := handler.Access.Load(withoutUser); ok && len(accessList) > 0 && !cmd.isAdmin {
552+ if checkAccess(accessList, cmd.userName, cmd.sesh) || accessListCreator {
553+ name = withoutUser
554+ } else if !*public {
555+ name = toTopic(cmd.userName, withoutUser)
556+ } else {
557+ return fmt.Errorf("access denied")
558+ }
559+ }
652560
653- isCreator := topic == ""
654- if isCreator {
655- topic = uuid.NewString()
656- }
561+ err := handler.PubSub.Sub(
562+ cmd.pipeCtx,
563+ clientID,
564+ cmd.sesh,
565+ []*psub.Channel{
566+ psub.NewChannel(name),
567+ },
568+ *keepAlive,
569+ )
570+
571+ if err != nil && !*clean {
572+ return err
573+ }
657574
658- var withoutUser string
659- var name string
660- flagMsg := ""
575+ return nil
576+}
661577
662- if isAdmin && strings.HasPrefix(topic, "/") {
663- name = strings.TrimPrefix(topic, "/")
664- } else {
665- name = toTopic(userName, topic)
666- if *public {
667- name = toPublicTopic(topic)
668- flagMsg = "-p "
669- withoutUser = name
670- } else {
671- withoutUser = topic
672- }
673- }
578+func (handler *CliHandler) pipe(cmd *CliCmd, topic string, clientID string) error {
579+ pipeCmd := flagSet("pipe", cmd.sesh)
580+ access := pipeCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic")
581+ public := pipeCmd.Bool("p", false, "Pipe to a public topic")
582+ replay := pipeCmd.Bool("r", false, "Replay messages to the client that sent it")
583+ clean := pipeCmd.Bool("c", false, "Don't send status messages")
674584
675- var accessListCreator bool
585+ if !flagCheck(pipeCmd, topic, cmd.args) {
586+ return fmt.Errorf("invalid cmd args")
587+ }
676588
677- _, loaded := handler.Access.LoadOrStore(name, accessList)
678- if !loaded {
679- defer func() {
680- handler.Access.Delete(name)
681- }()
589+ if pipeCmd.NArg() == 1 && topic == "" {
590+ topic = pipeCmd.Arg(0)
591+ }
682592
683- accessListCreator = true
684- }
593+ handler.Logger.Info(
594+ "flags parsed",
595+ "cmd", cmd,
596+ "public", *public,
597+ "replay", *replay,
598+ "topic", topic,
599+ "access", *access,
600+ "clean", *clean,
601+ )
685602
686- if accessList, ok := handler.Access.Load(withoutUser); ok && len(accessList) > 0 && !isAdmin {
687- if checkAccess(accessList, userName, sesh) || accessListCreator {
688- name = withoutUser
689- } else if !*public {
690- name = toTopic(userName, withoutUser)
691- } else {
692- topic = uuid.NewString()
693- name = toPublicTopic(topic)
694- }
695- }
603+ var accessList []string
696604
697- if isCreator && !*clean {
698- fmtTopic := topic
699- if *access != "" {
700- fmtTopic = fmt.Sprintf("%s/%s", userName, topic)
701- }
605+ if *access != "" {
606+ accessList = parseArgList(*access)
607+ }
702608
703- _, _ = fmt.Fprintf(
704- sesh,
705- "subscribe to this topic:\n ssh %s sub %s%s\n",
706- toSshCmd(handler.Cfg),
707- flagMsg,
708- fmtTopic,
709- )
710- }
609+ isCreator := topic == ""
610+ if isCreator {
611+ topic = uuid.NewString()
612+ }
711613
712- readErr, writeErr := pubsub.Pipe(
713- pipeCtx,
714- clientID,
715- sesh,
716- []*psub.Channel{
717- psub.NewChannel(name),
718- },
719- *replay,
720- )
721-
722- if readErr != nil && !*clean {
723- _, _ = fmt.Fprintln(sesh.Stderr(), "error reading from pipe", readErr)
724- }
614+ var withoutUser string
615+ var name string
616+ flagMsg := ""
617+
618+ if cmd.isAdmin && strings.HasPrefix(topic, "/") {
619+ name = strings.TrimPrefix(topic, "/")
620+ } else {
621+ name = toTopic(cmd.userName, topic)
622+ if *public {
623+ name = toPublicTopic(topic)
624+ flagMsg = "-p "
625+ withoutUser = name
626+ } else {
627+ withoutUser = topic
628+ }
629+ }
725630
726- if writeErr != nil && !*clean {
727- _, _ = fmt.Fprintln(sesh.Stderr(), "error writing to pipe", writeErr)
728- }
729- }
631+ var accessListCreator bool
730632
731- return next(sesh)
633+ _, loaded := handler.Access.LoadOrStore(name, accessList)
634+ if !loaded {
635+ defer func() {
636+ handler.Access.Delete(name)
637+ }()
638+ accessListCreator = true
639+ }
640+
641+ if accessList, ok := handler.Access.Load(withoutUser); ok && len(accessList) > 0 && !cmd.isAdmin {
642+ if checkAccess(accessList, cmd.userName, cmd.sesh) || accessListCreator {
643+ name = withoutUser
644+ } else if !*public {
645+ name = toTopic(cmd.userName, withoutUser)
646+ } else {
647+ topic = uuid.NewString()
648+ name = toPublicTopic(topic)
649+ }
650+ }
651+
652+ if isCreator && !*clean {
653+ fmtTopic := topic
654+ if *access != "" {
655+ fmtTopic = fmt.Sprintf("%s/%s", cmd.userName, topic)
656+ }
657+
658+ _, _ = fmt.Fprintf(
659+ cmd.sesh,
660+ "subscribe to this topic:\n ssh %s sub %s%s\n",
661+ toSshCmd(handler.Cfg),
662+ flagMsg,
663+ fmtTopic,
664+ )
665+ }
666+
667+ readErr, writeErr := handler.PubSub.Pipe(
668+ cmd.pipeCtx,
669+ clientID,
670+ cmd.sesh,
671+ []*psub.Channel{
672+ psub.NewChannel(name),
673+ },
674+ *replay,
675+ )
676+
677+ if readErr != nil && !*clean {
678+ return readErr
679+ }
680+
681+ if writeErr != nil && !*clean {
682+ return writeErr
683+ }
684+
685+ return nil
686+}
687+
688+func toSshCmd(cfg *shared.ConfigSite) string {
689+ port := ""
690+ if cfg.PortOverride != "22" {
691+ port = fmt.Sprintf("-p %s ", cfg.PortOverride)
692+ }
693+ return fmt.Sprintf("%s%s", port, cfg.Domain)
694+}
695+
696+// parseArgList parses a comma separated list of arguments.
697+func parseArgList(arg string) []string {
698+ argList := strings.Split(arg, ",")
699+ for i, acc := range argList {
700+ argList[i] = strings.TrimSpace(acc)
701+ }
702+ return argList
703+}
704+
705+// checkAccess checks if the user has access to a topic based on an access list.
706+func checkAccess(accessList []string, userName string, sesh *pssh.SSHServerConnSession) bool {
707+ for _, acc := range accessList {
708+ if acc == userName {
709+ return true
710+ }
711+
712+ if key := sesh.PublicKey(); key != nil && acc == gossh.FingerprintSHA256(key) {
713+ return true
732714 }
733715 }
716+
717+ return false
718+}
719+
720+func flagSet(cmdName string, sesh *pssh.SSHServerConnSession) *flag.FlagSet {
721+ cmd := flag.NewFlagSet(cmdName, flag.ContinueOnError)
722+ cmd.SetOutput(sesh)
723+ cmd.Usage = func() {
724+ _, _ = fmt.Fprintf(cmd.Output(), "Usage: %s <topic> [args...]\nArgs:\n", cmdName)
725+ cmd.PrintDefaults()
726+ }
727+ return cmd
728+}
729+
730+func flagCheck(cmd *flag.FlagSet, posArg string, cmdArgs []string) bool {
731+ err := cmd.Parse(cmdArgs)
732+
733+ if err != nil || posArg == "help" {
734+ if posArg == "help" {
735+ cmd.Usage()
736+ }
737+ return false
738+ }
739+ return true
740+}
741+
742+func NewTabWriter(out io.Writer) *tabwriter.Writer {
743+ return tabwriter.NewWriter(out, 0, 0, 1, ' ', tabwriter.TabIndent)
744+}
745+
746+// scope topic to user by prefixing name.
747+func toTopic(userName, topic string) string {
748+ if strings.HasPrefix(topic, userName+"/") {
749+ return topic
750+ }
751+ return fmt.Sprintf("%s/%s", userName, topic)
752+}
753+
754+func toPublicTopic(topic string) string {
755+ if strings.HasPrefix(topic, "public/") {
756+ return topic
757+ }
758+ return fmt.Sprintf("public/%s", topic)
759+}
760+
761+func clientInfo(clients []*psub.Client, isAdmin bool, clientType string) string {
762+ if len(clients) == 0 {
763+ return ""
764+ }
765+
766+ outputData := fmt.Sprintf(" %s:\r\n", clientType)
767+
768+ for _, client := range clients {
769+ if strings.HasPrefix(client.ID, "admin-") && !isAdmin {
770+ continue
771+ }
772+
773+ outputData += fmt.Sprintf(" - %s\r\n", client.ID)
774+ }
775+
776+ return outputData
734777 }