Each SSH session runs its own model over its own terminal, confined to the served directory (012 serve [dir]). The server listens on 127.0.0.1:2312 by default, accepts only public keys listed in an authorized_keys file (read again on every login, refused if others can write it, keys with options 012 can't honor skipped), generates an ed25519 host key (0600, in a 0700 directory) when there is none, and offers nothing but a shell: no exec, subsystems, port forwarding or password auth. Sessions end after an idle timeout without input and are capped in number. Logins, rejections and sessions are logged to stderr and telemetry with user, key fingerprint and remote address. JEV runs when the server's own environment has an API key, with one cache per session; sessions never read .env files.
- commit
- 59d49485013326455fe55aed142c11dffd22dae3
- parent
- 69ce041e212492e29885fe0c2f6e6b82765bf997
- author
- Dami <dami.etoile@gmail.com>
| M | cmd/ | 4 | + |
| A | cmd/ | 129 | +++++++++++ |
| A | cmd/ | 27 | ++ |
| M | go.mod | 27 | ++- |
| M | go.sum | 72 | ++++-- |
| A | internal/ | 155 | +++++++++++++ |
| A | internal/ | 126 | +++++++++++ |
| A | internal/ | 75 | ++++++ |
| A | internal/ | 296 | +++++++++++++++++++++++++ |
| A | internal/ | 354 | ++++++++++++++++++++++++++++++ |
@@ -24,6 +24,10 @@ func main() {
2424 }2525 2626 func run(args []string) error {27+ if len(args) > 0 && args[0] == "serve" {28+ // A file called serve opens as ./serve.29+ return runServe(args[1:])30+ }2731 tc, args, err := telemetryFlags(args)2832 if err != nil || len(args) > 1 {2933 return errors.New("usage: 012 [--log file.jsonl] [--otlp http://localhost:4318] [file]: a " + sheet.FileExt + " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import")@@ -0,0 +1,129 @@
1+package main2+3+import (4+ "context"5+ "errors"6+ "flag"7+ "fmt"8+ "io"9+ "log/slog"10+ "net"11+ "os"12+ "os/signal"13+ "syscall"14+ "time"15+16+ gossh "golang.org/x/crypto/ssh"17+18+ "github.com/FelineStateMachine/012/internal/jev"19+ "github.com/FelineStateMachine/012/internal/serve"20+)21+22+const serveUsage = "usage: 012 serve [--listen addr] [--authorized-keys file] [--host-key file] [--idle-timeout 30m] [--max-sessions 8] [dir]"23+24+// serveFlags parses 012 serve's arguments into options, starting from25+// the defaults. The served directory may come before or after the flags.26+func serveFlags(args []string, stderr io.Writer) (serve.Options, error) {27+ o := serve.Defaults()28+ fs := flag.NewFlagSet("012 serve", flag.ContinueOnError)29+ fs.SetOutput(stderr)30+ fs.Usage = func() {31+ fmt.Fprintln(stderr, serveUsage)32+ fs.PrintDefaults()33+ }34+ fs.StringVar(&o.Listen, "listen", o.Listen, "address to listen on")35+ fs.StringVar(&o.AuthorizedKeys, "authorized-keys", o.AuthorizedKeys, "public keys allowed to log in, in authorized_keys format")36+ fs.StringVar(&o.HostKey, "host-key", o.HostKey, "the server's private key, generated if missing")37+ fs.DurationVar(&o.IdleTimeout, "idle-timeout", o.IdleTimeout, "end sessions without input for this long (0: never)")38+ fs.IntVar(&o.MaxSessions, "max-sessions", o.MaxSessions, "most sessions at once")39+ var dirs []string40+ for {41+ if err := fs.Parse(args); err != nil {42+ return o, err43+ }44+ if fs.NArg() == 0 {45+ break46+ }47+ dirs, args = append(dirs, fs.Arg(0)), fs.Args()[1:]48+ }49+ switch len(dirs) {50+ case 0:51+ case 1:52+ o.Dir = dirs[0]53+ default:54+ return o, errors.New(serveUsage)55+ }56+ return o, nil57+}58+59+// runServe is 012 serve: sessions over SSH until interrupted.60+func runServe(args []string) error {61+ tc, args, err := telemetryFlags(args)62+ if err != nil {63+ return err64+ }65+ o, err := serveFlags(args, os.Stderr)66+ if errors.Is(err, flag.ErrHelp) {67+ return nil68+ }69+ if err != nil {70+ return err71+ }72+ stopTelemetry, err := startTelemetry(tc)73+ if err != nil {74+ return err75+ }76+ defer stopTelemetry()77+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))78+ d := serve.Deps{Log: log}79+ // JEV functions run when the server's own environment has an API80+ // key; sessions never read .env files.81+ if cfg, ok := jev.LoadConfig(); ok {82+ if d.JEV, err = jev.NewClient(cfg); err != nil {83+ return fmt.Errorf("JEV: %w", err)84+ }85+ }86+ srv, err := serve.New(o, d)87+ if err != nil {88+ return err89+ }90+ l, err := net.Listen("tcp", o.Listen)91+ if err != nil {92+ return err93+ }94+ if host, _, err := net.SplitHostPort(o.Listen); err == nil {95+ if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {96+ log.Warn("listening beyond this machine: anyone holding an authorized key who can reach " + o.Listen + " can read and write " + srv.Dir())97+ }98+ }99+ fmt.Fprintf(os.Stderr, "012 serving %s on %s\nhost key %s\nconnect: ssh -p %s %s\n",100+ srv.Dir(), l.Addr(), gossh.FingerprintSHA256(srv.HostKey()), portOf(l.Addr()), hostOf(l.Addr()))101+102+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)103+ defer stop()104+ done := make(chan error, 1)105+ go func() { done <- srv.Serve(l) }()106+ select {107+ case err := <-done:108+ return err109+ case <-ctx.Done():110+ }111+ // Stop listening, give sessions a moment, then drop what's left.112+ log.Info("stopping", "sessions", srv.Sessions())113+ sctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)114+ defer cancel()115+ if err := srv.Shutdown(sctx); err != nil {116+ srv.Close()117+ }118+ return <-done119+}120+121+func portOf(a net.Addr) string {122+ _, port, _ := net.SplitHostPort(a.String())123+ return port124+}125+126+func hostOf(a net.Addr) string {127+ host, _, _ := net.SplitHostPort(a.String())128+ return host129+}@@ -0,0 +1,27 @@
1+package main2+3+import (4+ "io"5+ "testing"6+ "time"7+8+ "github.com/FelineStateMachine/012/internal/serve"9+)10+11+func TestServeFlags(t *testing.T) {12+ o, err := serveFlags(nil, io.Discard)13+ if err != nil || o != serve.Defaults() {14+ t.Errorf("no flags: %+v, %v; want the defaults", o, err)15+ }16+ o, err = serveFlags([]string{"--listen", "127.0.0.1:9000", "sheets", "-idle-timeout=5m", "--max-sessions", "3",17+ "--authorized-keys", "/k", "--host-key=/h"}, io.Discard)18+ want := serve.Options{Dir: "sheets", Listen: "127.0.0.1:9000", AuthorizedKeys: "/k", HostKey: "/h", IdleTimeout: 5 * time.Minute, MaxSessions: 3}19+ if err != nil || o != want {20+ t.Errorf("got %+v, %v; want %+v", o, err, want)21+ }22+ for _, args := range [][]string{{"a", "b"}, {"--nope"}, {"--max-sessions", "x"}} {23+ if _, err := serveFlags(args, io.Discard); err == nil {24+ t.Errorf("%q: no error", args)25+ }26+ }27+}@@ -5,49 +5,60 @@ go 1.27.1
55 require (66 charm.land/bubbletea/v2 v2.0.1077 charm.land/lipgloss/v2 v2.0.68+ charm.land/ssh v0.4.39+ charm.land/wish/v2 v2.0.4810 github.com/FelineStateMachine/typesafe-go v0.2.09- github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f9788611+ github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd91012 github.com/charmbracelet/x/ansi v0.11.81113 github.com/cockroachdb/apd/v3 v3.2.31214 github.com/parquet-go/parquet-go v0.32.01315 github.com/sahilm/fuzzy v0.1.31416 github.com/xuri/excelize/v2 v2.11.015- golang.org/x/text v0.38.017+ golang.org/x/crypto v0.57.018+ golang.org/x/text v0.42.01619 modernc.org/sqlite v1.59.01720 )1821 1922 require (23+ charm.land/log/v2 v2.0.1 // indirect2024 github.com/andybalholm/brotli v1.1.1 // indirect25+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect2126 github.com/charmbracelet/colorprofile v0.4.3 // indirect27+ github.com/charmbracelet/keygen v0.5.4 // indirect28+ github.com/charmbracelet/x/conpty v0.2.0 // indirect2229 github.com/charmbracelet/x/term v0.2.2 // indirect2330 github.com/charmbracelet/x/termios v0.1.1 // indirect2431 github.com/charmbracelet/x/windows v0.2.2 // indirect32+ github.com/charmbracelet/x/xpty v0.1.4 // indirect2533 github.com/clipperhouse/displaywidth v0.11.0 // indirect2634 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect35+ github.com/creack/pty v1.1.24 // indirect2736 github.com/dustin/go-humanize v1.0.1 // indirect37+ github.com/go-logfmt/logfmt v0.6.1 // indirect2838 github.com/google/uuid v1.6.0 // indirect2939 github.com/klauspost/compress v1.17.9 // indirect3040 github.com/lucasb-eyer/go-colorful v1.4.1 // indirect3141 github.com/mattn/go-isatty v0.0.24 // indirect32- github.com/mattn/go-runewidth v0.0.24 // indirect42+ github.com/mattn/go-runewidth v0.0.30 // indirect3343 github.com/muesli/cancelreader v0.2.2 // indirect3444 github.com/ncruces/go-strftime v1.0.0 // indirect3545 github.com/parquet-go/bitpack v1.0.0 // indirect3646 github.com/parquet-go/jsonlite v1.0.0 // indirect3747 github.com/pierrec/lz4/v4 v4.1.21 // indirect48+ github.com/pires/go-proxyproto v0.15.0 // indirect3849 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect3950 github.com/richardlehane/mscfb v1.0.7 // indirect4051 github.com/richardlehane/msoleps v1.0.6 // indirect4152 github.com/rivo/uniseg v0.4.7 // indirect4253 github.com/tiendc/go-deepcopy v1.7.2 // indirect4354 github.com/twpayne/go-geom v1.6.1 // indirect44- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect55+ github.com/xo/terminfo v1.0.0 // indirect4556 github.com/xuri/efp v0.0.1 // indirect4657 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect47- golang.org/x/crypto v0.53.0 // indirect48- golang.org/x/net v0.56.0 // indirect49- golang.org/x/sync v0.22.0 // indirect50- golang.org/x/sys v0.47.0 // indirect58+ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect59+ golang.org/x/net v0.59.0 // indirect60+ golang.org/x/sync v0.23.0 // indirect61+ golang.org/x/sys v0.48.0 // indirect5162 google.golang.org/protobuf v1.34.2 // indirect5263 modernc.org/libc v1.75.7 // indirect5364 modernc.org/mathutil v1.7.1 // indirect@@ -2,6 +2,12 @@ charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
22 charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=33 charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=44 charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=5+charm.land/log/v2 v2.0.1 h1:uMk+t1hM/fomIqt7vn/Ub0AKo7IHvnDu6NK17nbOVx0=6+charm.land/log/v2 v2.0.1/go.mod h1:VXZNB8zoPPEIcvd7bRi2fpQpGCWweCSfWOwczA9dibs=7+charm.land/ssh v0.4.3 h1:hr5cYmlUYsP+KxyG/ug7ZMzKKN9f6VOD9yPpcGqdzzM=8+charm.land/ssh v0.4.3/go.mod h1:so/3IECPNlYZSnE7JKn7NFmcUyyxJqIAeM4TJy35qPk=9+charm.land/wish/v2 v2.0.4 h1:GcXfPaag1GNp2FqL6A1/zR4s8ae6sPHBU0fxigE4PZI=10+charm.land/wish/v2 v2.0.4/go.mod h1:l09dLMs+e7kLT70z7ZPvcsrOBuqiURHUAnkRSs4cvdU=511 github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=612 github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=713 github.com/FelineStateMachine/typesafe-go v0.2.0 h1:7YFrdcsuvTvBf0dhOvifZVbM8d4StrckWJC3yEH3Ydk=@@ -12,14 +18,20 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
1218 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=1319 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=1420 github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=21+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=22+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=1523 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=1624 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=1725 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=1826 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=19-github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=20-github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=27+github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=28+github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=29+github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 h1:tYBWVoMfQHTwp88mWeWH7o0uJFZqRJeEGJmeHpIR8Ms=30+github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=2131 github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=2232 github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=33+github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=34+github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=2335 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=2436 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=2537 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=@@ -28,18 +40,24 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
2840 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=2941 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=3042 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=43+github.com/charmbracelet/x/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4=44+github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU=3145 github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=3246 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=3347 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=3448 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=3549 github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=3650 github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=51+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=52+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=3753 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=3854 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=3955 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=4056 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=41-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=42-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=57+github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=58+github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=59+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=60+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=4361 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=4462 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=4563 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=@@ -58,8 +76,8 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql
5876 github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=5977 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=6078 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=61-github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=62-github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=79+github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=80+github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=6381 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=6482 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=6583 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=@@ -72,6 +90,8 @@ github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayM
7290 github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=7391 github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=7492 github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=93+github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=94+github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=7595 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=7696 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=7797 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=@@ -90,8 +110,8 @@ github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrI
90110 github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=91111 github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=92112 github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=93-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=94-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=113+github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=114+github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=95115 github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=96116 github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=97117 github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=@@ -100,26 +120,26 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
100120 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=101121 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=102122 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=103-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=104-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=105-golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=106-golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=123+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=124+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=125+golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=126+golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=107127 golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=108128 golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=109-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=110-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=111-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=112-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=113-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=114-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=115-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=116-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=117-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=118-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=119-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=120-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=121-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=122-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=129+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=130+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=131+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=132+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=133+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=134+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=135+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=136+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=137+golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=138+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=139+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=140+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=141+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=142+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=123143 google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=124144 google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=125145 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=@@ -0,0 +1,155 @@
1+package serve2+3+import (4+ "bufio"5+ "bytes"6+ "crypto/ed25519"7+ "crypto/rand"8+ "encoding/pem"9+ "errors"10+ "fmt"11+ "io/fs"12+ "os"13+ "path/filepath"14+ "strings"15+16+ gossh "golang.org/x/crypto/ssh"17+)18+19+// hostKey loads the server's private key from path, generating an20+// ed25519 key there first if there's none. A key others can read or21+// write is refused, as sshd refuses one.22+func hostKey(path string) (gossh.Signer, error) {23+ st, err := os.Stat(path)24+ if errors.Is(err, fs.ErrNotExist) {25+ if err := generateHostKey(path); err != nil {26+ return nil, fmt.Errorf("generating the host key: %w", err)27+ }28+ st, err = os.Stat(path)29+ }30+ if err != nil {31+ return nil, err32+ }33+ if st.Mode().Perm()&0o077 != 0 {34+ return nil, fmt.Errorf("host key %s is accessible by others (%v); chmod 600 it", path, st.Mode().Perm())35+ }36+ data, err := os.ReadFile(path)37+ if err != nil {38+ return nil, err39+ }40+ s, err := gossh.ParsePrivateKey(data)41+ if err != nil {42+ return nil, fmt.Errorf("host key %s: %w", path, err)43+ }44+ return s, nil45+}46+47+// generateHostKey writes a new ed25519 key to path (0600) and its public48+// half beside it, creating the directory (0700) if needed.49+func generateHostKey(path string) error {50+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {51+ return err52+ }53+ pub, priv, err := ed25519.GenerateKey(rand.Reader)54+ if err != nil {55+ return err56+ }57+ block, err := gossh.MarshalPrivateKey(priv, "012 host key")58+ if err != nil {59+ return err60+ }61+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)62+ if err != nil {63+ return err64+ }65+ _, err = f.Write(pem.EncodeToMemory(block))66+ if cerr := f.Close(); err == nil {67+ err = cerr68+ }69+ if err != nil {70+ os.Remove(path)71+ return err72+ }73+ sshPub, err := gossh.NewPublicKey(pub)74+ if err != nil {75+ return err76+ }77+ return os.WriteFile(path+".pub", gossh.MarshalAuthorizedKey(sshPub), 0o644)78+}79+80+// safeOptions are the authorized_keys options a key may carry and still81+// be accepted: they only turn off things 012 never offers. Keys with any82+// other option (command=, from=, no-pty, expiry-time= and so on) are83+// skipped, since 012 can't honor the restriction.84+var safeOptions = map[string]bool{85+ "no-port-forwarding": true,86+ "no-agent-forwarding": true,87+ "no-x11-forwarding": true,88+ "no-user-rc": true,89+ "restrict": true,90+ "pty": true,91+}92+93+// authorizedKeys reads the keys allowed to log in from path, in94+// OpenSSH's format. It fails if the file can be written by others, as95+// sshd's StrictModes does, or if a line doesn't parse. skipped counts96+// keys left out for their options.97+func authorizedKeys(path string) (keys []gossh.PublicKey, skipped int, err error) {98+ st, err := os.Stat(path)99+ if err != nil {100+ return nil, 0, err101+ }102+ if !st.Mode().IsRegular() {103+ return nil, 0, fmt.Errorf("%s is not a regular file", path)104+ }105+ if st.Mode().Perm()&0o022 != 0 {106+ return nil, 0, fmt.Errorf("%s can be written by others (%v); chmod go-w it", path, st.Mode().Perm())107+ }108+ data, err := os.ReadFile(path)109+ if err != nil {110+ return nil, 0, err111+ }112+ sc := bufio.NewScanner(bytes.NewReader(data))113+ sc.Buffer(nil, 1<<20)114+ for n := 1; sc.Scan(); n++ {115+ line := bytes.TrimSpace(sc.Bytes())116+ if len(line) == 0 || line[0] == '#' {117+ continue118+ }119+ key, _, opts, _, err := gossh.ParseAuthorizedKey(line)120+ if err != nil {121+ return nil, 0, fmt.Errorf("%s:%d: %w", path, n, err)122+ }123+ if !acceptable(opts) {124+ skipped++125+ continue126+ }127+ keys = append(keys, key)128+ }129+ return keys, skipped, sc.Err()130+}131+132+// acceptable reports whether a key with these authorized_keys options133+// may log in to 012.134+func acceptable(opts []string) bool {135+ restricted, pty := false, false136+ for _, o := range opts {137+ o = strings.ToLower(o)138+ if !safeOptions[o] {139+ return false140+ }141+ restricted = restricted || o == "restrict"142+ pty = pty || o == "pty"143+ }144+ // restrict takes the terminal away unless pty gives it back, and 012145+ // needs one.146+ return !restricted || pty147+}148+149+// fingerprint is a key's SHA256 fingerprint, as ssh-keygen -l shows it.150+func fingerprint(k gossh.PublicKey) string {151+ if k == nil {152+ return ""153+ }154+ return gossh.FingerprintSHA256(k)155+}@@ -0,0 +1,126 @@
1+package serve2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ gossh "golang.org/x/crypto/ssh"10+)11+12+func TestAuthorizedKeys(t *testing.T) {13+ keys := []gossh.Signer{newKey(t), newKey(t), newKey(t), newKey(t), newKey(t), newKey(t)}14+ line := func(i int, opts string) string {15+ s := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(keys[i].PublicKey())))16+ if opts != "" {17+ s = opts + " " + s18+ }19+ return s + " user@host\n"20+ }21+ content := "# comment\n\n" +22+ line(0, "") +23+ line(1, `command="rrsync /backup"`) + // a restricted backup key: not for 01224+ line(2, `from="10.0.0.0/8"`) + // 012 can't check where it comes from25+ line(3, "restrict") + // no terminal26+ line(4, "restrict,pty") +27+ line(5, "no-port-forwarding,no-agent-forwarding")28+ path := filepath.Join(t.TempDir(), "authorized_keys")29+ os.WriteFile(path, []byte(content), 0o600)30+ got, skipped, err := authorizedKeys(path)31+ if err != nil {32+ t.Fatal(err)33+ }34+ want := []int{0, 4, 5}35+ if len(got) != len(want) || skipped != 3 {36+ t.Fatalf("got %d keys, skipped %d; want %d and 3", len(got), skipped, len(want))37+ }38+ for i, k := range want {39+ if fingerprint(got[i]) != fingerprint(keys[k].PublicKey()) {40+ t.Errorf("key %d is not key %d", i, k)41+ }42+ }43+44+ // Writable by others: refused, as sshd's StrictModes does.45+ os.Chmod(path, 0o620)46+ if _, _, err := authorizedKeys(path); err == nil || !strings.Contains(err.Error(), "written by others") {47+ t.Errorf("group-writable file: %v", err)48+ }49+ os.Chmod(path, 0o644)50+ if _, _, err := authorizedKeys(path); err != nil {51+ t.Errorf("0644 file: %v", err)52+ }53+54+ // A line that doesn't parse names its line.55+ os.WriteFile(path, []byte(line(0, "")+"ssh-ed25519 notbase64!\n"), 0o600)56+ if _, _, err := authorizedKeys(path); err == nil || !strings.Contains(err.Error(), ":2:") {57+ t.Errorf("bad line: %v", err)58+ }59+ if _, _, err := authorizedKeys(filepath.Join(t.TempDir(), "missing")); err == nil {60+ t.Error("missing file accepted")61+ }62+ if _, _, err := authorizedKeys(t.TempDir()); err == nil {63+ t.Error("a directory accepted")64+ }65+}66+67+func TestHostKey(t *testing.T) {68+ dir := filepath.Join(t.TempDir(), "config", "012")69+ path := filepath.Join(dir, "ssh_host_ed25519_key")70+ s1, err := hostKey(path)71+ if err != nil {72+ t.Fatal(err)73+ }74+ if s1.PublicKey().Type() != gossh.KeyAlgoED25519 {75+ t.Errorf("host key type %s", s1.PublicKey().Type())76+ }77+ for p, want := range map[string]os.FileMode{path: 0o600, dir: 0o700 | os.ModeDir, path + ".pub": 0o644} {78+ if st, err := os.Stat(p); err != nil || st.Mode() != want {79+ t.Errorf("%s: mode %v, %v; want %v", filepath.Base(p), st.Mode(), err, want)80+ }81+ }82+ // Loaded again, not regenerated.83+ s2, err := hostKey(path)84+ if err != nil || fingerprint(s2.PublicKey()) != fingerprint(s1.PublicKey()) {85+ t.Errorf("reloaded host key differs: %v", err)86+ }87+ os.Chmod(path, 0o644)88+ if _, err := hostKey(path); err == nil || !strings.Contains(err.Error(), "chmod 600") {89+ t.Errorf("readable host key: %v", err)90+ }91+}92+93+func TestNewChecks(t *testing.T) {94+ key := newKey(t)95+ top := t.TempDir()96+ ak := filepath.Join(top, "authorized_keys")97+ os.WriteFile(ak, gossh.MarshalAuthorizedKey(key.PublicKey()), 0o600)98+ empty := filepath.Join(top, "empty")99+ os.WriteFile(empty, []byte("# nobody\n"), 0o600)100+ base := Options{Dir: top, Listen: "127.0.0.1:0", AuthorizedKeys: ak, HostKey: filepath.Join(top, "hk"), MaxSessions: 1}101+ cases := map[string]func(*Options){102+ "missing dir": func(o *Options) { o.Dir = filepath.Join(top, "nope") },103+ "no keys": func(o *Options) { o.AuthorizedKeys = empty },104+ "missing keys": func(o *Options) { o.AuthorizedKeys = filepath.Join(top, "nope") },105+ "no listen": func(o *Options) { o.Listen = "" },106+ "no sessions": func(o *Options) { o.MaxSessions = 0 },107+ "negative idle": func(o *Options) { o.IdleTimeout = -1 },108+ "no host key": func(o *Options) { o.HostKey = "" },109+ "no authorized keys": func(o *Options) { o.AuthorizedKeys = "" },110+ }111+ for name, change := range cases {112+ o := base113+ change(&o)114+ if _, err := New(o, Deps{}); err == nil {115+ t.Errorf("%s: New succeeded", name)116+ }117+ }118+ if _, err := New(base, Deps{}); err != nil {119+ t.Errorf("valid options: %v", err)120+ }121+ d := Defaults()122+ if d.Listen != "127.0.0.1:2312" || !strings.HasSuffix(d.AuthorizedKeys, filepath.Join(".ssh", "authorized_keys")) ||123+ !strings.HasSuffix(d.HostKey, filepath.Join("012", "ssh_host_ed25519_key")) || d.IdleTimeout <= 0 || d.MaxSessions <= 0 {124+ t.Errorf("Defaults() = %+v", d)125+ }126+}@@ -0,0 +1,75 @@
1+// Package serve runs 012 as an SSH server: each session gets its own2+// spreadsheet over its own terminal, confined to one directory.3+//4+// It is built for one person reaching their own files from elsewhere,5+// not for sharing: public-key auth against an authorized_keys file only,6+// on the loopback address unless told otherwise, with no port7+// forwarding, no commands and no subsystems. See docs/ssh.md.8+package serve9+10+import (11+ "errors"12+ "fmt"13+ "os"14+ "path/filepath"15+ "time"16+)17+18+// Options are 012 serve's settings, one field per flag. Defaults gives19+// their default values; cmd/012 parses flags into them.20+type Options struct {21+ // Dir is the served directory: sessions open, save, import and22+ // download only inside it.23+ Dir string24+ // Listen is the TCP address to listen on.25+ Listen string26+ // AuthorizedKeys is the file of public keys allowed to connect, in27+ // OpenSSH's authorized_keys format. It is read again on every login.28+ AuthorizedKeys string29+ // HostKey is the server's private key file, an ed25519 key generated30+ // there (0600, in a 0700 directory) when missing.31+ HostKey string32+ // IdleTimeout ends a session that has had no input for this long; 033+ // never does.34+ IdleTimeout time.Duration35+ // MaxSessions is how many sessions may run at once; more are turned36+ // away.37+ MaxSessions int38+}39+40+// Default values.41+const (42+ DefaultListen = "127.0.0.1:2312"43+ DefaultIdleTimeout = 30 * time.Minute44+ DefaultMaxSessions = 845+)46+47+// Defaults returns the default options: the current directory, served on48+// the loopback address, keys from ~/.ssh/authorized_keys and the host key49+// in 012's config directory.50+func Defaults() Options {51+ o := Options{Dir: ".", Listen: DefaultListen, IdleTimeout: DefaultIdleTimeout, MaxSessions: DefaultMaxSessions}52+ if home, err := os.UserHomeDir(); err == nil {53+ o.AuthorizedKeys = filepath.Join(home, ".ssh", "authorized_keys")54+ }55+ if cfg, err := os.UserConfigDir(); err == nil {56+ o.HostKey = filepath.Join(cfg, "012", "ssh_host_ed25519_key")57+ }58+ return o59+}60+61+func (o Options) validate() error {62+ switch {63+ case o.Listen == "":64+ return errors.New("no address to listen on")65+ case o.AuthorizedKeys == "":66+ return errors.New("no authorized_keys file")67+ case o.HostKey == "":68+ return errors.New("no host key file")69+ case o.MaxSessions < 1:70+ return fmt.Errorf("max sessions is %d, it must be at least 1", o.MaxSessions)71+ case o.IdleTimeout < 0:72+ return errors.New("the idle timeout can't be negative")73+ }74+ return nil75+}@@ -0,0 +1,296 @@
1+package serve2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "io"8+ "log/slog"9+ "net"10+ "sync/atomic"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+ "charm.land/ssh"15+ bm "charm.land/wish/v2/bubbletea"16+ gossh "golang.org/x/crypto/ssh"17+18+ "github.com/FelineStateMachine/012/internal/confine"19+ "github.com/FelineStateMachine/012/internal/jev"20+ "github.com/FelineStateMachine/012/internal/sheet"21+ "github.com/FelineStateMachine/012/internal/telemetry"22+ "github.com/FelineStateMachine/012/internal/ui"23+)24+25+// handshakeTimeout bounds the time from connecting to logging in.26+const handshakeTimeout = 30 * time.Second27+28+// Server serves 012 sessions over SSH.29+type Server struct {30+ opts Options31+ root confine.Root32+ log *slog.Logger33+ jev jev.Client // nil: JEV functions are off34+ ssh *ssh.Server35+ hostPub gossh.PublicKey36+ slots chan struct{} // one per running session37+ active atomic.Int6438+}39+40+// Deps are what a Server takes from its surroundings rather than its41+// options.42+type Deps struct {43+ // Log gets a line per login, rejection and session, besides the44+ // telemetry events; nil discards them.45+ Log *slog.Logger46+ // JEV answers JEV functions, with a cache per session; nil turns them47+ // off.48+ JEV jev.Client49+}50+51+// New checks o and prepares a server: the served directory, the host52+// key (generated if missing) and the authorized keys must be usable.53+func New(o Options, d Deps) (*Server, error) {54+ if err := o.validate(); err != nil {55+ return nil, err56+ }57+ root, err := confine.New(o.Dir)58+ if err != nil {59+ return nil, fmt.Errorf("served directory: %w", err)60+ }61+ keys, _, err := authorizedKeys(o.AuthorizedKeys)62+ if err != nil {63+ return nil, fmt.Errorf("authorized keys: %w", err)64+ }65+ if len(keys) == 0 {66+ return nil, fmt.Errorf("authorized keys: no usable keys in %s", o.AuthorizedKeys)67+ }68+ signer, err := hostKey(o.HostKey)69+ if err != nil {70+ return nil, err71+ }72+ if d.Log == nil {73+ d.Log = slog.New(slog.DiscardHandler)74+ }75+ s := &Server{opts: o, root: root, log: d.Log, jev: d.JEV, hostPub: signer.PublicKey(), slots: make(chan struct{}, o.MaxSessions)}76+ s.ssh = &ssh.Server{77+ Addr: o.Listen,78+ Handler: s.session,79+ HostSigners: []ssh.Signer{signer},80+ Version: "012",81+ PublicKeyHandler: s.authorize,82+ // No password or keyboard-interactive auth, no forwarding of any83+ // kind, no global requests and no subsystems: only sessions.84+ ChannelHandlers: map[string]ssh.ChannelHandler{"session": ssh.DefaultSessionHandler},85+ RequestHandlers: map[string]ssh.RequestHandler{},86+ SubsystemHandlers: map[string]ssh.SubsystemHandler{},87+ SessionRequestCallback: allowShell,88+ ConnectionFailedCallback: func(c net.Conn, err error) {89+ s.event("ssh.failed", slog.String("remote", c.RemoteAddr().String()), slog.String("error", err.Error()))90+ },91+ HandshakeTimeout: handshakeTimeout,92+ }93+ if o.IdleTimeout > 0 {94+ // A backstop for connections without a session. Sessions end95+ // on their own after IdleTimeout without input (see run), which96+ // keepalives don't reset.97+ s.ssh.IdleTimeout = o.IdleTimeout + time.Minute98+ }99+ return s, nil100+}101+102+// allowShell lets a session start 012 and nothing else: exec requests103+// (ssh host command) and subsystems (sftp) are refused.104+func allowShell(sess ssh.Session, requestType string) bool {105+ return requestType == "shell"106+}107+108+// HostKey is the server's public key, for known_hosts.109+func (s *Server) HostKey() gossh.PublicKey { return s.hostPub }110+111+// Dir is the served directory, with links resolved.112+func (s *Server) Dir() string { return s.root.Dir() }113+114+// Sessions is how many sessions are running.115+func (s *Server) Sessions() int { return int(s.active.Load()) }116+117+// ListenAndServe listens on the options' address and serves until118+// Shutdown or Close.119+func (s *Server) ListenAndServe() error {120+ l, err := net.Listen("tcp", s.opts.Listen)121+ if err != nil {122+ return err123+ }124+ return s.Serve(l)125+}126+127+// Serve serves sessions arriving on l.128+func (s *Server) Serve(l net.Listener) error {129+ s.log.Info("serving", "addr", l.Addr().String(), "dir", s.root.Dir(), "host_key", fingerprint(s.hostPub))130+ err := s.ssh.Serve(l)131+ if errors.Is(err, ssh.ErrServerClosed) {132+ return nil133+ }134+ return err135+}136+137+// Shutdown stops listening and waits for sessions to end, until ctx is138+// done.139+func (s *Server) Shutdown(ctx context.Context) error { return s.ssh.Shutdown(ctx) }140+141+// Close stops the server and drops every connection at once.142+func (s *Server) Close() error { return s.ssh.Close() }143+144+// authorize accepts key if the authorized_keys file lists it, reading145+// the file again so edits apply without a restart.146+func (s *Server) authorize(ctx ssh.Context, key ssh.PublicKey) bool {147+ keys, _, err := authorizedKeys(s.opts.AuthorizedKeys)148+ if err != nil {149+ s.event("ssh.auth_error", slog.String("error", err.Error()))150+ return false151+ }152+ for _, k := range keys {153+ if ssh.KeysEqual(key, k) {154+ return true155+ }156+ }157+ s.event("ssh.rejected", slog.String("user", ctx.User()), slog.String("key", fingerprint(key)),158+ slog.String("remote", ctx.RemoteAddr().String()))159+ return false160+}161+162+// event logs name to telemetry and the server log. Events name users,163+// keys and addresses, never what's in files.164+func (s *Server) event(name string, attrs ...slog.Attr) {165+ telemetry.Event(name, 0, attrs...)166+ s.log.LogAttrs(context.Background(), slog.LevelInfo, name, attrs...)167+}168+169+// session runs one 012 over one SSH session.170+func (s *Server) session(sess ssh.Session) {171+ who := []slog.Attr{slog.String("user", sess.User()), slog.String("key", fingerprint(sess.PublicKey())),172+ slog.String("remote", sess.RemoteAddr().String())}173+ pty, winch, ok := sess.Pty()174+ if !ok {175+ fmt.Fprint(sess.Stderr(), "012 needs a terminal: connect with ssh -t\r\n")176+ sess.Exit(1)177+ return178+ }179+ select {180+ case s.slots <- struct{}{}:181+ default:182+ s.event("ssh.full", who...)183+ fmt.Fprintf(sess.Stderr(), "012: %d sessions are running, the most this server allows; try again later\r\n", s.opts.MaxSessions)184+ sess.Exit(1)185+ return186+ }187+ n := s.active.Add(1)188+ telemetry.Set("ssh_sessions", n)189+ s.event("ssh.session", append(who, slog.String("term", pty.Term), slog.Int("width", pty.Window.Width), slog.Int("height", pty.Window.Height))...)190+ start := time.Now()191+ defer func() {192+ telemetry.Set("ssh_sessions", s.active.Add(-1))193+ <-s.slots194+ }()195+196+ idle := s.run(sess, pty, winch)197+ d := time.Since(start)198+ s.event("ssh.session_end", append(who, slog.Duration("duration", d), slog.Bool("idle", idle))...)199+ if idle {200+ fmt.Fprintf(sess, "012: closed after %v without input\r\n", s.opts.IdleTimeout)201+ }202+ sess.Exit(0)203+}204+205+// run runs the program until it quits, the client goes away or the206+// session goes idle, which it reports.207+func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window) (idle bool) {208+ m := ui.New(sheet.New(), "")209+ env := append(sess.Environ(), "TERM="+pty.Term)210+ m.Serve(s.root, env)211+ if s.jev != nil {212+ // Each session has its own cache: it queues that session's213+ // questions, and its answers recalculate that session's sheets.214+ m.EnableJEV(s.jev, jev.NewCache())215+ }216+ in := newActivity(sess)217+ p := tea.NewProgram(m, append(bm.MakeOptions(sess), tea.WithInput(in))...)218+219+ // The connection's context ends when the client goes away; cancel220+ // stops the goroutines below when the program quits first.221+ ctx, cancel := context.WithCancel(sess.Context())222+ defer cancel()223+ go resize(ctx, p, winch)224+ var idled atomic.Bool225+ if s.opts.IdleTimeout > 0 {226+ go func() {227+ if in.waitIdle(ctx, s.opts.IdleTimeout) {228+ idled.Store(true)229+ p.Quit()230+ }231+ }()232+ }233+ if _, err := p.Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) {234+ s.log.Error("session", "error", err.Error())235+ }236+ p.Kill()237+ return idled.Load()238+}239+240+// resize tells p about the client's window changes, and quits it when241+// ctx ends.242+func resize(ctx context.Context, p *tea.Program, winch <-chan ssh.Window) {243+ for {244+ select {245+ case <-ctx.Done():246+ p.Quit()247+ return248+ case w, ok := <-winch:249+ if !ok {250+ <-ctx.Done()251+ p.Quit()252+ return253+ }254+ p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})255+ }256+ }257+}258+259+// activity is a session's input, noting when it last had any.260+type activity struct {261+ r io.Reader262+ last atomic.Int64 // UnixNano of the last read that returned bytes263+}264+265+func newActivity(r io.Reader) *activity {266+ a := &activity{r: r}267+ a.last.Store(time.Now().UnixNano())268+ return a269+}270+271+func (a *activity) Read(p []byte) (int, error) {272+ n, err := a.r.Read(p)273+ if n > 0 {274+ a.last.Store(time.Now().UnixNano())275+ }276+ return n, err277+}278+279+// waitIdle waits until there's been no input for d, reporting true, or280+// until ctx ends, reporting false.281+func (a *activity) waitIdle(ctx context.Context, d time.Duration) bool {282+ t := time.NewTimer(d)283+ defer t.Stop()284+ for {285+ select {286+ case <-ctx.Done():287+ return false288+ case now := <-t.C:289+ left := d - now.Sub(time.Unix(0, a.last.Load()))290+ if left <= 0 {291+ return true292+ }293+ t.Reset(left)294+ }295+ }296+}@@ -0,0 +1,354 @@
1+package serve2+3+import (4+ "bytes"5+ "context"6+ "crypto/ed25519"7+ "crypto/rand"8+ "encoding/base64"9+ "errors"10+ "io"11+ "net"12+ "os"13+ "path/filepath"14+ "strings"15+ "sync"16+ "sync/atomic"17+ "testing"18+ "time"19+20+ typesafe "github.com/FelineStateMachine/typesafe-go"21+ gossh "golang.org/x/crypto/ssh"22+23+ "github.com/FelineStateMachine/012/internal/sheet"24+)25+26+// newKey makes a client key.27+func newKey(t testing.TB) gossh.Signer {28+ t.Helper()29+ _, priv, err := ed25519.GenerateKey(rand.Reader)30+ if err != nil {31+ t.Fatal(err)32+ }33+ s, err := gossh.NewSignerFromKey(priv)34+ if err != nil {35+ t.Fatal(err)36+ }37+ return s38+}39+40+// testServer serves a fresh directory on a random loopback port to the41+// given keys, returning the server, its address and the served42+// directory.43+func testServer(t testing.TB, change func(*Options), keys ...gossh.Signer) (*Server, string, string) {44+ t.Helper()45+ top := t.TempDir()46+ dir := filepath.Join(top, "served")47+ os.Mkdir(dir, 0o755)48+ var ak bytes.Buffer49+ for _, k := range keys {50+ ak.Write(gossh.MarshalAuthorizedKey(k.PublicKey()))51+ }52+ akPath := filepath.Join(top, "authorized_keys")53+ if err := os.WriteFile(akPath, ak.Bytes(), 0o600); err != nil {54+ t.Fatal(err)55+ }56+ o := Defaults()57+ o.Dir, o.AuthorizedKeys, o.HostKey, o.Listen = dir, akPath, filepath.Join(top, "config", "host_key"), "127.0.0.1:0"58+ if change != nil {59+ change(&o)60+ }61+ srv, err := New(o, Deps{})62+ if err != nil {63+ t.Fatal(err)64+ }65+ l, err := net.Listen("tcp", o.Listen)66+ if err != nil {67+ t.Fatal(err)68+ }69+ done := make(chan error, 1)70+ go func() { done <- srv.Serve(l) }()71+ t.Cleanup(func() {72+ srv.Close()73+ if err := <-done; err != nil {74+ t.Errorf("Serve: %v", err)75+ }76+ })77+ return srv, l.Addr().String(), srv.Dir()78+}79+80+func dial(addr string, key gossh.Signer, host gossh.PublicKey) (*gossh.Client, error) {81+ return gossh.Dial("tcp", addr, &gossh.ClientConfig{82+ User: "dami",83+ Auth: []gossh.AuthMethod{gossh.PublicKeys(key)},84+ HostKeyCallback: gossh.FixedHostKey(host),85+ Timeout: 5 * time.Second,86+ })87+}88+89+// term is a client session with a terminal, collecting what it's sent.90+type term struct {91+ sess *gossh.Session92+ stdin io.WriteCloser93+ mu sync.Mutex94+ out bytes.Buffer95+ done chan error96+}97+98+func openTerm(t testing.TB, c *gossh.Client, w, h int) *term {99+ t.Helper()100+ sess, err := c.NewSession()101+ if err != nil {102+ t.Fatal(err)103+ }104+ if err := sess.RequestPty("xterm-256color", h, w, gossh.TerminalModes{}); err != nil {105+ t.Fatal(err)106+ }107+ tm := &term{sess: sess, done: make(chan error, 1)}108+ tm.stdin, _ = sess.StdinPipe()109+ sess.Stdout = tm110+ if err := sess.Shell(); err != nil {111+ t.Fatal(err)112+ }113+ go func() { tm.done <- sess.Wait() }()114+ return tm115+}116+117+func (tm *term) Write(p []byte) (int, error) {118+ tm.mu.Lock()119+ defer tm.mu.Unlock()120+ return tm.out.Write(p)121+}122+123+func (tm *term) output() string {124+ tm.mu.Lock()125+ defer tm.mu.Unlock()126+ return tm.out.String()127+}128+129+// waitFor waits until the output holds each of want.130+func (tm *term) waitFor(t testing.TB, want ...string) {131+ t.Helper()132+ deadline := time.Now().Add(10 * time.Second)133+ for time.Now().Before(deadline) {134+ out := tm.output()135+ ok := true136+ for _, w := range want {137+ ok = ok && strings.Contains(out, w)138+ }139+ if ok {140+ return141+ }142+ time.Sleep(10 * time.Millisecond)143+ }144+ t.Fatalf("output never showed %q; got %q", want, tm.output())145+}146+147+// waitClosed waits for the session to end.148+func (tm *term) waitClosed(t testing.TB) {149+ t.Helper()150+ select {151+ case <-tm.done:152+ case <-time.After(10 * time.Second):153+ t.Fatal("the session didn't close")154+ }155+}156+157+// A session gets its own 012: the first screen renders at the client's158+// size, and quitting ends the session.159+func TestSession(t *testing.T) {160+ key := newKey(t)161+ srv, addr, dir := testServer(t, nil, key)162+ c, err := dial(addr, key, srv.HostKey())163+ if err != nil {164+ t.Fatal(err)165+ }166+ defer c.Close()167+ tm := openTerm(t, c, 90, 20)168+ tm.waitFor(t, "READY", "012 - untitled", "Sheet1")169+ if n := srv.Sessions(); n != 1 {170+ t.Errorf("Sessions() = %d", n)171+ }172+173+ // Type a number and save it: the file lands in the served directory.174+ io.WriteString(tm.stdin, "42\r")175+ // Copying goes to the client's clipboard, through its terminal176+ // (OSC 52), not the server's.177+ io.WriteString(tm.stdin, "\x1b[A\x03")178+ tm.waitFor(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte("42")))179+ io.WriteString(tm.stdin, "\x13") // Ctrl+S asks for a name180+ tm.waitFor(t, "Save as")181+ io.WriteString(tm.stdin, "kept\r")182+ waitFile(t, filepath.Join(dir, "kept.012"))183+184+ io.WriteString(tm.stdin, "\x11") // Ctrl+Q: saved, so it quits at once185+ tm.waitClosed(t)186+ deadline := time.Now().Add(5 * time.Second)187+ for srv.Sessions() != 0 && time.Now().Before(deadline) {188+ time.Sleep(10 * time.Millisecond)189+ }190+ if n := srv.Sessions(); n != 0 {191+ t.Errorf("Sessions() = %d after quitting", n)192+ }193+}194+195+func waitFile(t *testing.T, path string) {196+ t.Helper()197+ deadline := time.Now().Add(5 * time.Second)198+ for time.Now().Before(deadline) {199+ if f, err := os.Open(path); err == nil {200+ s, err := sheet.Read(f)201+ f.Close()202+ if err == nil && s.Cell(sheet.Addr{}) != nil && s.Cell(sheet.Addr{}).Input == "42" {203+ return204+ }205+ }206+ time.Sleep(10 * time.Millisecond)207+ }208+ t.Fatalf("%s wasn't saved", path)209+}210+211+// A key missing from authorized_keys can't log in, and neither can a212+// password.213+func TestRejected(t *testing.T) {214+ good, bad := newKey(t), newKey(t)215+ srv, addr, _ := testServer(t, nil, good)216+ if c, err := dial(addr, bad, srv.HostKey()); err == nil {217+ c.Close()218+ t.Fatal("a key not in authorized_keys logged in")219+ }220+ c, err := gossh.Dial("tcp", addr, &gossh.ClientConfig{221+ User: "dami",222+ Auth: []gossh.AuthMethod{gossh.Password("hunter2"), gossh.KeyboardInteractive(223+ func(string, string, []string, []bool) ([]string, error) { return nil, nil })},224+ HostKeyCallback: gossh.FixedHostKey(srv.HostKey()),225+ Timeout: 5 * time.Second,226+ })227+ if err == nil {228+ c.Close()229+ t.Fatal("password auth logged in")230+ }231+}232+233+// Logged in, a client can run 012 and nothing else: no commands, no234+// subsystems, no forwarding, no session without a terminal.235+func TestOnlyShell(t *testing.T) {236+ key := newKey(t)237+ srv, addr, _ := testServer(t, nil, key)238+ c, err := dial(addr, key, srv.HostKey())239+ if err != nil {240+ t.Fatal(err)241+ }242+ defer c.Close()243+244+ sess, _ := c.NewSession()245+ if out, err := sess.CombinedOutput("cat /etc/passwd"); err == nil || len(out) > 0 {246+ t.Errorf("exec ran: %q, %v", out, err)247+ }248+ sess, _ = c.NewSession()249+ if err := sess.RequestSubsystem("sftp"); err == nil {250+ t.Error("the sftp subsystem started")251+ }252+ if conn, err := c.Dial("tcp", "127.0.0.1:22"); err == nil {253+ conn.Close()254+ t.Error("local port forwarding was allowed")255+ }256+ if l, err := c.Listen("tcp", "127.0.0.1:0"); err == nil {257+ l.Close()258+ t.Error("remote port forwarding was allowed")259+ }260+ // A shell without a terminal is told to ask for one and closed.261+ sess, _ = c.NewSession()262+ var stderr bytes.Buffer263+ sess.Stderr = &stderr264+ sess.Stdout = io.Discard265+ if err := sess.Shell(); err != nil {266+ t.Fatal(err)267+ }268+ var exit *gossh.ExitError269+ if err := sess.Wait(); !errors.As(err, &exit) || exit.ExitStatus() != 1 || !strings.Contains(stderr.String(), "needs a terminal") {270+ t.Errorf("shell without a terminal: %v, %q", err, stderr.String())271+ }272+}273+274+// Sessions past the limit are turned away; a place frees when one ends.275+func TestMaxSessions(t *testing.T) {276+ key := newKey(t)277+ srv, addr, _ := testServer(t, func(o *Options) { o.MaxSessions = 1 }, key)278+ c, err := dial(addr, key, srv.HostKey())279+ if err != nil {280+ t.Fatal(err)281+ }282+ defer c.Close()283+ first := openTerm(t, c, 80, 24)284+ first.waitFor(t, "READY")285+ second := openTerm(t, c, 80, 24)286+ second.waitClosed(t)287+ io.WriteString(first.stdin, "\x11")288+ first.waitClosed(t)289+ third := openTerm(t, c, 80, 24)290+ third.waitFor(t, "READY")291+}292+293+// A session without input for the idle timeout is closed with a note.294+func TestIdleTimeout(t *testing.T) {295+ key := newKey(t)296+ srv, addr, _ := testServer(t, func(o *Options) { o.IdleTimeout = 300 * time.Millisecond }, key)297+ c, err := dial(addr, key, srv.HostKey())298+ if err != nil {299+ t.Fatal(err)300+ }301+ defer c.Close()302+ tm := openTerm(t, c, 80, 24)303+ tm.waitFor(t, "READY")304+ tm.waitClosed(t)305+ if !strings.Contains(tm.output(), "closed after 300ms without input") {306+ t.Errorf("no idle note in %q", tm.output()[max(0, len(tm.output())-200):])307+ }308+}309+310+// fakeJEV answers every question with 0.9 (90%), slowly enough311+// that two sessions' questions are in flight together.312+type fakeJEV struct{ calls atomic.Int64 }313+314+func (f *fakeJEV) SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {315+ f.calls.Add(1)316+ time.Sleep(50 * time.Millisecond)317+ return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": typesafe.NoulAnswer{Noul: 0.9}}}, nil318+}319+320+// Two sessions are two separate spreadsheets: what one types, the other321+// doesn't see, and each gets the answers to its own JEV questions.322+func TestSessionsAreSeparate(t *testing.T) {323+ key := newKey(t)324+ fake := &fakeJEV{}325+ srv, addr, _ := testServer(t, nil, key)326+ srv.jev = fake327+ c, err := dial(addr, key, srv.HostKey())328+ if err != nil {329+ t.Fatal(err)330+ }331+ defer c.Close()332+ a, b := openTerm(t, c, 80, 24), openTerm(t, c, 100, 30)333+ a.waitFor(t, "READY")334+ b.waitFor(t, "READY")335+ io.WriteString(a.stdin, "only in a\r")336+ a.waitFor(t, "only in a")337+ for _, tm := range []*term{a, b} {338+ io.WriteString(tm.stdin, `=JEV.PROB("the box was crushed", "Is this a complaint?")`+"\r")339+ }340+ a.waitFor(t, "90%")341+ b.waitFor(t, "90%")342+ if strings.Contains(b.output(), "only in a") {343+ t.Error("session b shows what session a typed")344+ }345+ if n := fake.calls.Load(); n != 2 {346+ t.Errorf("%d JEV requests; want one per session", n)347+ }348+ for _, tm := range []*term{a, b} {349+ io.WriteString(tm.stdin, "\x11")350+ tm.waitFor(t, "unsaved changes")351+ io.WriteString(tm.stdin, "d")352+ tm.waitClosed(t)353+ }354+}