add.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ db/help-en | 29 +++++++++++++++++++++++++++++ db/help-ru | 29 +++++++++++++++++++++++++++++ db/motd | 5 +++++ get.go | 26 ++++++++++++++++++++++++++ go.mod | 8 ++++++++ go.sum | 4 ++++ main.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ new.go | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++ notify.go | 38 ++++++++++++++++++++++++++++++++++++++ diff --git a/add.go b/add.go new file mode 100644 index 0000000000000000000000000000000000000000..5cd014a6be58fdd97a012b5562c3f394d51f6d75d757f61f2a7166dd206379ce --- /dev/null +++ b/add.go @@ -0,0 +1,47 @@ +package main + +import ( + "bufio" + "errors" + "os" + "path" + + "github.com/google/uuid" + "go.stargrave.org/recfile/v4" +) + +func doAdd() error { + if len(Args) != 2 { + return errors.New("no id supplied") + } + id, err := uuid.Parse(Args[1]) + if err != nil { + return err + } + body, err := msgRead() + if err != nil { + return err + } + fn := path.Join(DB, id.String()) + fh, err := os.OpenFile(fn, os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + return err + } + defer fh.Close() + bw := bufio.NewWriter(fh) + w := recfile.NewWriter(bw) + if err = metaWrite(w); err != nil { + return err + } + if _, err = w.WriteField("body", body); err != nil { + return err + } + if err = bw.Flush(); err != nil { + return err + } + if err = fh.Close(); err != nil { + return err + } + notify(id.String()) + return nil +} diff --git a/db/help-en b/db/help-en new file mode 100644 index 0000000000000000000000000000000000000000..2050dbf9778b9929e4b595e6069df06c8a8f9e95c61e8d47737c3b133ca5387e --- /dev/null +++ b/db/help-en @@ -0,0 +1,29 @@ +To leave a message to operator, call the system with "new" command: + + $ ssh -C msg@msg.stargrave.org new The subject line < Multiline + > message body + > I want to leave + > EOF + < cd6e391a-9eec-46be-9f39-84f6011944f5 + +That cd6e391a-9eec-46be-9f39-84f6011944f5 is thread identifier. + +Now you can check if there is any reply left from the operator: + + $ ssh -C msg@msg.stargrave.org get cd6e391a-9eec-46be-9f39-84f6011944f5 + subj: The subject line for the thread + + when: 2026-05-06 14:26:27+03:00 + from: ::1 64254 22 + body: + + Multiline + + message body + + I want to leave + +To add/reply another message to that thread: + + $ ssh -C msg@msg.stargrave.org add cd6e391a-9eec-46be-9f39-84f6011944f5 < Reply to the existing thread. + > Add another message. + > EOF diff --git a/db/help-ru b/db/help-ru new file mode 100644 index 0000000000000000000000000000000000000000..f4348f4d9aa711d87801fbb205355717aea0f04f23aaf15aa5bf493d6c840699 --- /dev/null +++ b/db/help-ru @@ -0,0 +1,29 @@ +Чтобы оставить сообщение оператору, вызовите систему с "new" командой: + + $ ssh -C msg@msg.stargrave.org new Это тема сообщения < Многострочное + > тело сообщения + > которое я хочу оставить + > EOF + < cd6e391a-9eec-46be-9f39-84f6011944f5 + +Этот cd6e391a-9eec-46be-9f39-84f6011944f5 является идентификатором нити. + +Теперь вы можете проверить не оставил ли оператор ответа: + + $ ssh -C msg@msg.stargrave.org get cd6e391a-9eec-46be-9f39-84f6011944f5 + subj: Это тема сообщения + + when: 2026-05-06 14:26:27+03:00 + from: ::1 64254 22 + body: + + Многострочное + + тело сообщения + + которое я хочу оставить + +Чтобы добавить/ответить другим сообщением в этой нити: + + $ ssh -C msg@msg.stargrave.org add cd6e391a-9eec-46be-9f39-84f6011944f5 < Ответ в существующую нить. + > Добавить ещё одно сообщение. + > EOF diff --git a/db/motd b/db/motd new file mode 100644 index 0000000000000000000000000000000000000000..bfe82e7606225f78f884ccac222096db14bae36f94fb65ef54b8f019c0e1c767 --- /dev/null +++ b/db/motd @@ -0,0 +1,5 @@ + Welcome to simple messaging system! + Добро пожаловать в простую систему обмена сообщениями! +-------------------------------------------------------------------------------- +To get help use: ssh msg@msg.stargrave.org get help-en +Для получения справки используйте: ssh msg@msg.stargrave.org get help-ru diff --git a/get.go b/get.go new file mode 100644 index 0000000000000000000000000000000000000000..5c51aa3ea188a98cec8a261f8dd02d14b426bc3ebbf75ccad761fe8e35c1a3ec --- /dev/null +++ b/get.go @@ -0,0 +1,26 @@ +package main + +import ( + "bufio" + "errors" + "io" + "os" + "path" + "regexp" +) + +func doGet() error { + if len(Args) != 2 { + return errors.New("no id supplied") + } + re := regexp.MustCompile("^[a-zA-Z0-9-]+$") + if !re.MatchString(Args[1]) { + return errors.New("bad id supplied") + } + fh, err := os.Open(path.Join(DB, Args[1])) + if err != nil { + return err + } + _, err = io.Copy(os.Stdout, bufio.NewReader(fh)) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..3d3181d024efd3addb13ab91012f7b388fac05d1c974a5cbf22aaf761049cda4 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module go.stargrave.org/msshg + +go 1.26.2 + +require ( + github.com/google/uuid v1.6.0 + go.stargrave.org/recfile/v4 v4.0.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..68c75d66df8dd5f2661f24eed59159a1a2d1fcaabe49d260b17c860050e9e8c3 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.stargrave.org/recfile/v4 v4.0.0 h1:v9kZnW/e64oPfOqArGMNZacuT57lDibd24xhEiBDQ/c= +go.stargrave.org/recfile/v4 v4.0.0/go.mod h1:IGi8kMociIlDoR8iOssyUG/uix+wmsqvBuiDeQXsIuE= diff --git a/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..6abdeac75dde2bbac8de3b1a7d75270d2396846e2454e9a57f185a45b5ee42b2 --- /dev/null +++ b/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "bufio" + "errors" + "io" + "log" + "os" + "path" + "strings" +) + +const MaxMsgLen = 1 << 20 + +var ( + DB, Host string + Args []string +) + +func main() { + log.SetFlags(0) + log.SetOutput(os.Stdout) + DB = os.Args[1] + Args = strings.Split(os.Getenv("SSH_ORIGINAL_COMMAND"), " ") + if Args[0] == "" { + fh, err := os.Open(path.Join(DB, "motd")) + if err != nil { + log.Fatal(err) + } + _, err = io.Copy(os.Stdout, bufio.NewReader(fh)) + if err != nil { + log.Fatal(err) + } + return + } + var err error + switch Args[0] { + case "new": + err = doNew() + case "add": + err = doAdd() + case "get": + err = doGet() + default: + err = errors.New("unknown command supplied") + } + if err != nil { + log.Fatal(err) + } +} diff --git a/new.go b/new.go new file mode 100644 index 0000000000000000000000000000000000000000..d88f6165c9a9d1ca6b546c3881906e87cb0e44299ac1e9f9216017ecf67ceec4 --- /dev/null +++ b/new.go @@ -0,0 +1,107 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path" + "strings" + "time" + + "github.com/google/uuid" + "go.stargrave.org/recfile/v4" +) + +func msgRead() ([]string, error) { + var buf bytes.Buffer + copied := make(chan error) + go func() { + _, err := io.CopyN(&buf, os.Stdin, MaxMsgLen) + if err == io.EOF { + copied <- nil + return + } + if err == nil { + copied <- errors.New("too large") + return + } + }() + t := time.NewTimer(time.Minute) + select { + case <-t.C: + return nil, errors.New("timeout") + case err := <-copied: + if err != nil { + return nil, err + } + } + return append([]string{""}, + strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")...), nil +} + +func metaWrite(w *recfile.Writer) (err error) { + if _, err = w.RecordStart(); err != nil { + return err + } + if _, err = w.WriteFields(recfile.Field{ + F: "when", + V: time.Now().Format("2006-01-02 15:04:05Z07:00"), + }); err != nil { + return err + } + if _, err = w.WriteFields(recfile.Field{ + F: "from", + V: os.Getenv("SSH_CLIENT"), + }); err != nil { + return err + } + return nil +} + +func doNew() error { + subj := strings.Join(Args[1:], " ") + if subj == "" { + return errors.New("empty subject") + } + body, err := msgRead() + if err != nil { + return err + } + id, err := uuid.NewV7() + if err != nil { + return err + } + fn := path.Join(DB, id.String()+".tmp") + fh, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666) + if err != nil { + return err + } + defer os.Remove(fn) + defer fh.Close() + bw := bufio.NewWriter(fh) + w := recfile.NewWriter(bw) + if _, err = w.WriteFields(recfile.Field{F: "subj", V: subj}); err != nil { + return err + } + if err = metaWrite(w); err != nil { + return err + } + if _, err = w.WriteField("body", body); err != nil { + return err + } + if err = bw.Flush(); err != nil { + return err + } + if err = fh.Close(); err != nil { + return err + } + if err = os.Rename(fn, fn[:len(fn)-4]); err != nil { + return err + } + fmt.Println(id.String()) + notify(id.String()) + return nil +} diff --git a/notify.go b/notify.go new file mode 100644 index 0000000000000000000000000000000000000000..cbc43a062d352c8662eb0eb1c37a1fa7c7934ccd701b3d5bf2913e510d4db87d --- /dev/null +++ b/notify.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path" + "strings" +) + +const SendmailCmd = "/usr/sbin/sendmail" + +func notify(id string) { + var to string + { + data, err := os.ReadFile(path.Join(DB, "notify")) + if err != nil { + return + } + to = strings.TrimRight(string(data), "\n") + } + cmd := exec.Command(SendmailCmd, to) + cmd.Stdin = io.MultiReader( + strings.NewReader(fmt.Sprintf( + `From: msshg +To: %s +Subject: %s +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +`, + to, id, + )), + strings.NewReader(""), + ) + cmd.Run() +}