@synopackageland/cli 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0 (2026-10-29)
4
+
5
+ - Audit the target Linux import graphs of configured Go binaries rather than
6
+ recursively scanning unshipped development commands.
7
+ - Accept fixed, permission-declared `api.HandleHTTP` routes and package-private
8
+ `ipc.ServeUnix` handler graphs with SDK 0.33.0. Reject direct listeners and
9
+ unregistered handlers even when an IPC handler is reused.
10
+ - Bind the raw HTTP prefix inventory and package IPC identity into each Go
11
+ binary. Repeat source validation after UI hooks. Retain executor 0.32.0 bytes
12
+ and templates 0.32.0.
13
+
3
14
  ## 0.34.0 (2026-10-29)
4
15
 
5
16
  - Enforce explicit App entry and reject undeclared App methods, dynamic route
package/README.md CHANGED
@@ -74,15 +74,18 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
74
74
  packages. Private mirrors can use explicitly configured Go authentication and
75
75
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
76
76
 
77
- CLI 0.34.0 uses templates 0.32.0 and SDK 0.32.0. Compose import and SDK defaults
77
+ CLI 0.35.0 uses templates ~0.32.0, whose default SDK pin remains 0.32.0. Compose import and SDK defaults
78
78
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
79
79
  Every App method must be declared in the permissions table. Dynamic names and
80
- custom HTTP dispatchers are rejected; use fixed SDK route constructors and
80
+ custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
81
+ `api.HandleHTTP` prefixes for authorized raw HTTP and `ipc.ServeUnix` for private
82
+ package Unix sockets. The audit follows each shipped Linux binary's import graph;
83
+ unimported development commands are excluded. Use
81
84
  `identity_gateway.upstream_auth_paths` for client API paths that use upstream
82
85
  authentication. See [App entry and registration](docs/app-entry.md).
83
86
 
84
87
  `init` and `add` read their templates from the locally installed
85
- `@synopackageland/templates`. CLI 0.34.0 depends on `~0.32.0`: npm installation or
88
+ `@synopackageland/templates`. CLI 0.35.0 depends on `~0.32.0`: npm installation or
86
89
  update can select the latest templates patch in the 0.32 minor line. Templates
87
90
  patches need only a templates release; update the npm installation to receive them.
88
91
  The CLI never fetches templates at runtime, so offline use works after installation;
@@ -2,26 +2,302 @@
2
2
  package main
3
3
 
4
4
  import (
5
+ "bytes"
5
6
  "encoding/json"
6
7
  "fmt"
7
8
  "go/ast"
8
9
  "go/parser"
9
10
  "go/token"
11
+ "io"
10
12
  "os"
13
+ "os/exec"
11
14
  "path/filepath"
15
+ "regexp"
16
+ "sort"
12
17
  "strconv"
13
18
  "strings"
14
19
  )
15
20
 
16
21
  type input struct {
17
22
  Dir string
23
+ Roots []string
24
+ Arches []string
18
25
  Permissions map[string]string
19
26
  SDK map[string]map[string]string
20
27
  }
21
28
 
22
29
  type result struct {
23
- Problems []string `json:"problems"`
24
- EntryGate bool `json:"entryGate"`
30
+ Problems []string `json:"problems"`
31
+ EntryGate bool `json:"entryGate"`
32
+ HTTPRoutes []httpRoute `json:"httpRoutes"`
33
+ }
34
+
35
+ type httpRoute struct {
36
+ Prefix string `json:"prefix"`
37
+ Method string `json:"method"`
38
+ }
39
+
40
+ const sdkModule = "github.com/synopackageland/syno-pkg-land-sdk"
41
+
42
+ // Resolve target build constraints without running any application code.
43
+ func shippedFiles(in input) (map[string]string, error) {
44
+ files := map[string]string{}
45
+ if _, err := os.Stat(filepath.Join(in.Dir, "go.mod")); os.IsNotExist(err) {
46
+ matches, err := filepath.Glob(filepath.Join(in.Dir, "*.go"))
47
+ for _, path := range matches {
48
+ if !strings.HasSuffix(path, "_test.go") {
49
+ files[path] = "fixture"
50
+ }
51
+ }
52
+ return files, err
53
+ }
54
+ roots, arches := in.Roots, in.Arches
55
+ if len(roots) == 0 {
56
+ roots = []string{"."}
57
+ }
58
+ if len(arches) == 0 {
59
+ arches = []string{"amd64", "arm64", "arm"}
60
+ }
61
+ for _, arch := range arches {
62
+ cmd := exec.Command("go", append([]string{"list", "-e", "-mod=readonly", "-deps", "-json"}, roots...)...)
63
+ cmd.Dir = in.Dir
64
+ cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+arch, "GOARM=7", "CGO_ENABLED=0", "GOWORK=off", "GOFLAGS=")
65
+ raw, err := cmd.Output()
66
+ if err != nil {
67
+ return nil, fmt.Errorf("cannot resolve shipped Go import graph")
68
+ }
69
+ d := json.NewDecoder(bytes.NewReader(raw))
70
+ for {
71
+ var p struct {
72
+ Dir, ImportPath string
73
+ GoFiles []string
74
+ Standard bool
75
+ DepOnly bool
76
+ Error *struct{ Err string }
77
+ Module *struct {
78
+ Main bool
79
+ Replace *struct{ Dir string }
80
+ }
81
+ }
82
+ err := d.Decode(&p)
83
+ if err == io.EOF {
84
+ break
85
+ }
86
+ if err != nil {
87
+ return nil, err
88
+ }
89
+ if !p.DepOnly && p.Error != nil {
90
+ return nil, fmt.Errorf("cannot resolve a configured shipped Go root")
91
+ }
92
+ if p.Standard || p.ImportPath == sdkModule || strings.HasPrefix(p.ImportPath, sdkModule+"/") {
93
+ continue
94
+ }
95
+ if p.Module == nil || !p.Module.Main && p.Module.Replace == nil {
96
+ continue
97
+ }
98
+ for _, name := range p.GoFiles {
99
+ files[filepath.Join(p.Dir, name)] = p.ImportPath
100
+ }
101
+ }
102
+ }
103
+ if len(files) == 0 {
104
+ return nil, fmt.Errorf("shipped Go roots contain no auditable source")
105
+ }
106
+ return files, nil
107
+ }
108
+
109
+ func stringLiteral(expr ast.Expr) string {
110
+ if v, ok := expr.(*ast.BasicLit); ok && v.Kind == token.STRING {
111
+ s, _ := strconv.Unquote(v.Value)
112
+ return s
113
+ }
114
+ return ""
115
+ }
116
+
117
+ type source struct {
118
+ file *ast.File
119
+ path, pkg string
120
+ imports map[string]string
121
+ }
122
+
123
+ // Follow the handler argument through local declarations and factories. A
124
+ // boundary never exempts an entire directory, file, or any listener primitive.
125
+ func boundaryHandlers(sources []source) map[ast.Node]bool {
126
+ decls := map[string][]ast.Node{}
127
+ owners := map[ast.Node]source{}
128
+ for _, s := range sources {
129
+ ast.Inspect(s.file, func(n ast.Node) bool {
130
+ if n != nil {
131
+ owners[n] = s
132
+ }
133
+ return true
134
+ })
135
+ for _, decl := range s.file.Decls {
136
+ switch d := decl.(type) {
137
+ case *ast.FuncDecl:
138
+ key := s.pkg + "." + d.Name.Name
139
+ if d.Recv != nil {
140
+ t := d.Recv.List[0].Type
141
+ if v, ok := t.(*ast.StarExpr); ok {
142
+ t = v.X
143
+ }
144
+ if v, ok := t.(*ast.Ident); ok {
145
+ key = s.pkg + "." + v.Name
146
+ }
147
+ }
148
+ decls[key] = append(decls[key], d)
149
+ case *ast.GenDecl:
150
+ for _, spec := range d.Specs {
151
+ switch v := spec.(type) {
152
+ case *ast.TypeSpec:
153
+ decls[s.pkg+"."+v.Name.Name] = append(decls[s.pkg+"."+v.Name.Name], v)
154
+ case *ast.ValueSpec:
155
+ for _, name := range v.Names {
156
+ decls[s.pkg+"."+name.Name] = append(decls[s.pkg+"."+name.Name], v)
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ safe, visited := map[ast.Node]bool{}, map[ast.Node]bool{}
164
+ var mark func(ast.Node)
165
+ mark = func(root ast.Node) {
166
+ if root == nil || visited[root] {
167
+ return
168
+ }
169
+ visited[root] = true
170
+ s := owners[root]
171
+ ast.Inspect(root, func(n ast.Node) bool {
172
+ if n == nil {
173
+ return false
174
+ }
175
+ safe[n] = true
176
+ key := ""
177
+ switch v := n.(type) {
178
+ case *ast.Ident:
179
+ key = s.pkg + "." + v.Name
180
+ if v.Obj != nil {
181
+ if d, ok := v.Obj.Decl.(ast.Node); ok {
182
+ mark(d)
183
+ }
184
+ }
185
+ case *ast.SelectorExpr:
186
+ if id, ok := v.X.(*ast.Ident); ok {
187
+ key = s.imports[id.Name] + "." + v.Sel.Name
188
+ }
189
+ }
190
+ for _, decl := range decls[key] {
191
+ mark(decl)
192
+ }
193
+ return true
194
+ })
195
+ }
196
+ for _, s := range sources {
197
+ ast.Inspect(s.file, func(n ast.Node) bool {
198
+ call, ok := n.(*ast.CallExpr)
199
+ if !ok || len(call.Args) != 3 {
200
+ return true
201
+ }
202
+ sel, ok := call.Fun.(*ast.SelectorExpr)
203
+ if !ok {
204
+ return true
205
+ }
206
+ id, ok := sel.X.(*ast.Ident)
207
+ if ok && (s.imports[id.Name] == sdkModule+"/api" && sel.Sel.Name == "HandleHTTP" ||
208
+ s.imports[id.Name] == sdkModule+"/ipc" && sel.Sel.Name == "ServeUnix") {
209
+ mark(call.Args[2])
210
+ }
211
+ return true
212
+ })
213
+ }
214
+ return safe
215
+ }
216
+
217
+ // A handler admitted to a private/raw boundary cannot also be installed as
218
+ // the CGI root. SDK server construction ends this search because it gates
219
+ // its registered routes before invoking their handlers.
220
+ func reusesBoundary(expr ast.Node, owner source, sources []source, safe map[ast.Node]bool, seen map[ast.Node]bool) bool {
221
+ if expr == nil || seen[expr] {
222
+ return false
223
+ }
224
+ seen[expr] = true
225
+ imports := owner.imports
226
+ if call, ok := expr.(*ast.CallExpr); ok {
227
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
228
+ if id, ok := sel.X.(*ast.Ident); ok && imports[id.Name] == sdkModule+"/api" {
229
+ switch sel.Sel.Name {
230
+ case "NewServerWithPermissions":
231
+ return false
232
+ case "WithRequestDeadline", "WithDescription":
233
+ return len(call.Args) > 0 && reusesBoundary(call.Args[0], owner, sources, safe, seen)
234
+ }
235
+ }
236
+ }
237
+ }
238
+ found := false
239
+ ast.Inspect(expr, func(n ast.Node) bool {
240
+ if n == nil || found {
241
+ return false
242
+ }
243
+ if n != expr {
244
+ if _, ok := n.(*ast.CallExpr); ok {
245
+ found = reusesBoundary(n, owner, sources, safe, seen)
246
+ return false
247
+ }
248
+ }
249
+ targetPackage, targetName := "", ""
250
+ if id, ok := n.(*ast.Ident); ok {
251
+ if id.Obj != nil {
252
+ if decl, ok := id.Obj.Decl.(ast.Node); ok {
253
+ found = safe[decl] || reusesBoundary(decl, owner, sources, safe, seen)
254
+ }
255
+ } else {
256
+ targetPackage, targetName = owner.pkg, id.Name
257
+ }
258
+ }
259
+ if selector, ok := n.(*ast.SelectorExpr); ok {
260
+ if pkg, ok := selector.X.(*ast.Ident); ok {
261
+ targetPackage, targetName = imports[pkg.Name], selector.Sel.Name
262
+ }
263
+ }
264
+ if targetPackage != "" {
265
+ for _, candidate := range sources {
266
+ if candidate.pkg != targetPackage {
267
+ continue
268
+ }
269
+ for _, decl := range candidate.file.Decls {
270
+ var target ast.Node
271
+ switch d := decl.(type) {
272
+ case *ast.FuncDecl:
273
+ if d.Recv == nil && d.Name.Name == targetName {
274
+ target = d
275
+ }
276
+ case *ast.GenDecl:
277
+ for _, spec := range d.Specs {
278
+ switch v := spec.(type) {
279
+ case *ast.TypeSpec:
280
+ if v.Name.Name == targetName {
281
+ target = v
282
+ }
283
+ case *ast.ValueSpec:
284
+ for _, name := range v.Names {
285
+ if name.Name == targetName {
286
+ target = v
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ if target != nil && (safe[target] || reusesBoundary(target, candidate, sources, safe, seen)) {
293
+ found = true
294
+ }
295
+ }
296
+ }
297
+ }
298
+ return !found
299
+ })
300
+ return found
25
301
  }
26
302
 
27
303
  func main() {
@@ -30,7 +306,7 @@ func main() {
30
306
  fmt.Fprintln(os.Stderr, err)
31
307
  os.Exit(1)
32
308
  }
33
- out := result{Problems: []string{}}
309
+ out := result{Problems: []string{}, HTTPRoutes: []httpRoute{}}
34
310
  sdk := map[string]bool{}
35
311
  for _, group := range in.SDK {
36
312
  for method := range group {
@@ -38,50 +314,55 @@ func main() {
38
314
  }
39
315
  }
40
316
  set := token.NewFileSet()
41
- err := filepath.WalkDir(in.Dir, func(path string, d os.DirEntry, err error) error {
317
+ files, err := shippedFiles(in)
318
+ if err != nil {
319
+ fmt.Fprintln(os.Stderr, err)
320
+ os.Exit(1)
321
+ }
322
+ var sources []source
323
+ for path, pkg := range files {
324
+ file, err := parser.ParseFile(set, path, nil, 0)
42
325
  if err != nil {
43
- return err
44
- }
45
- if d.IsDir() {
46
- if path != in.Dir && (strings.HasPrefix(d.Name(), ".") || d.Name() == "vendor" || d.Name() == "node_modules") {
47
- return filepath.SkipDir
48
- }
49
- return nil
326
+ fmt.Fprintln(os.Stderr, "invalid shipped Go source")
327
+ os.Exit(1)
50
328
  }
51
- if d.Type()&os.ModeSymlink != 0 {
52
- target, err := os.Stat(path)
53
- if err == nil && target.IsDir() {
54
- shown, _ := filepath.Rel(in.Dir, path)
55
- out.Problems = append(out.Problems, filepath.ToSlash(shown)+":0: Go source directories cannot be symlinks")
56
- return nil
329
+ imports := map[string]string{}
330
+ for _, spec := range file.Imports {
331
+ module, _ := strconv.Unquote(spec.Path.Value)
332
+ alias := filepath.Base(module)
333
+ if spec.Name != nil {
334
+ alias = spec.Name.Name
57
335
  }
336
+ imports[alias] = module
58
337
  }
59
- if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
60
- return nil
61
- }
338
+ sources = append(sources, source{file, path, pkg, imports})
339
+ }
340
+ safe := boundaryHandlers(sources)
341
+ for _, src := range sources {
342
+ path, file, imports := src.path, src.file, src.imports
62
343
  shown, _ := filepath.Rel(in.Dir, path)
63
344
  problem := func(pos token.Pos, message string) {
64
345
  out.Problems = append(out.Problems, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(shown), set.Position(pos).Line, message))
65
346
  }
66
- if d.Type()&os.ModeSymlink != 0 {
347
+ d, err := os.Lstat(path)
348
+ if err != nil || d.Mode()&os.ModeSymlink != 0 {
67
349
  problem(token.NoPos, "Go source symlinks cannot be audited")
68
- return nil
350
+ continue
69
351
  }
70
- file, err := parser.ParseFile(set, path, nil, 0)
71
- if err != nil {
72
- return fmt.Errorf("%s: invalid Go source", shown)
352
+ for dir := filepath.Dir(path); dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
353
+ info, err := os.Lstat(dir)
354
+ if err == nil && info.Mode()&os.ModeSymlink != 0 {
355
+ problem(token.NoPos, "Go source directories cannot be symlinks")
356
+ break
357
+ }
358
+ if dir == in.Dir {
359
+ break
360
+ }
73
361
  }
74
- imports := map[string]string{}
75
362
  for _, spec := range file.Imports {
76
- module, _ := strconv.Unquote(spec.Path.Value)
77
- alias := filepath.Base(module)
78
- if spec.Name != nil {
79
- alias = spec.Name.Name
80
- }
81
- if alias == "." {
363
+ if spec.Name != nil && spec.Name.Name == "." {
82
364
  problem(spec.Pos(), "dot imports cannot be audited")
83
365
  }
84
- imports[alias] = module
85
366
  }
86
367
  constants := map[string]string{}
87
368
  direct := map[*ast.SelectorExpr]bool{}
@@ -108,25 +389,36 @@ func main() {
108
389
  }
109
390
  }
110
391
  ast.Inspect(file, func(n ast.Node) bool {
111
- if fn, ok := n.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name.Name == "ServeHTTP" {
112
- problem(fn.Pos(), "custom ServeHTTP bypasses the SDK registry; use api.HandleRequest, HandleResponse or HandleStream")
392
+ if fn, ok := n.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name.Name == "ServeHTTP" && !safe[n] {
393
+ problem(fn.Pos(), "unregistered ServeHTTP; use api.HandleHTTP or ipc.ServeUnix")
113
394
  }
114
395
  selector, ok := n.(*ast.SelectorExpr)
115
396
  if !ok {
116
397
  return true
117
398
  }
399
+ if selector.Sel.Name == "ServeHTTP" && !safe[n] {
400
+ problem(selector.Pos(), "unregistered ServeHTTP dispatch")
401
+ }
118
402
  pkg, ok := selector.X.(*ast.Ident)
119
403
  if !ok {
120
404
  return true
121
405
  }
122
406
  module := imports[pkg.Name]
123
407
  name := selector.Sel.Name
408
+ if module == "net" && (strings.HasPrefix(name, "Listen") || name == "FileListener" || name == "FilePacketConn") ||
409
+ module == "crypto/tls" && name == "Listen" ||
410
+ (module == "syscall" || module == "golang.org/x/sys/unix") && (name == "Bind" || name == "Listen" || name == "Socket") {
411
+ problem(selector.Pos(), "custom listener outside SDK boundaries: "+name)
412
+ }
413
+ if module == sdkModule+"/ipc" && name == "ServeUnix" && !direct[selector] {
414
+ problem(selector.Pos(), "IPC constructor aliases cannot be audited")
415
+ }
124
416
  if module == "github.com/synopackageland/syno-pkg-land-sdk/idgateway" && (name == "NewFront" || name == "Build" || name == "Run") {
125
417
  problem(selector.Pos(), "gateway listeners must use identity_gateway in the manifest; upstream_auth_paths never exempts SDK RPC or custom HTTP handlers")
126
418
  }
127
419
  if module == "github.com/synopackageland/syno-pkg-land-sdk/api" {
128
420
  switch name {
129
- case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch":
421
+ case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP":
130
422
  if !direct[selector] {
131
423
  problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
132
424
  }
@@ -134,8 +426,20 @@ func main() {
134
426
  }
135
427
  if module == "net/http" {
136
428
  switch name {
137
- case "Handle", "HandleFunc", "HandlerFunc", "NewServeMux", "Server", "Serve", "ServeTLS", "ListenAndServe", "ListenAndServeTLS":
429
+ case "Handle", "HandleFunc", "Server", "Serve", "ServeTLS", "ListenAndServe", "ListenAndServeTLS":
138
430
  problem(selector.Pos(), "HTTP handler outside the SDK registry: "+name)
431
+ case "HandlerFunc", "NewServeMux", "ServeMux", "FileServer", "FileServerFS", "StripPrefix", "TimeoutHandler", "NotFoundHandler", "RedirectHandler":
432
+ if !safe[n] {
433
+ problem(selector.Pos(), "HTTP handler outside the SDK registry: "+name)
434
+ }
435
+ }
436
+ }
437
+ if module == "net/http/httputil" && (name == "ReverseProxy" || name == "NewSingleHostReverseProxy") && !safe[n] {
438
+ problem(selector.Pos(), "HTTP proxy outside the SDK registry")
439
+ }
440
+ if module == "net/http/cgi" && name == "Serve" {
441
+ if !direct[selector] {
442
+ problem(selector.Pos(), "CGI constructor aliases cannot be audited")
139
443
  }
140
444
  }
141
445
  return true
@@ -154,6 +458,9 @@ func main() {
154
458
  return true
155
459
  }
156
460
  module := imports[pkg.Name]
461
+ if module == "net/http/cgi" && selector.Sel.Name == "Serve" && len(call.Args) > 0 && reusesBoundary(call.Args[0], src, sources, safe, map[ast.Node]bool{}) {
462
+ problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
463
+ }
157
464
  if module == "github.com/synopackageland/syno-pkg-land-sdk/dsm" && selector.Sel.Name == "NewEntryGate" {
158
465
  out.EntryGate = true
159
466
  }
@@ -161,13 +468,17 @@ func main() {
161
468
  return true
162
469
  }
163
470
  switch selector.Sel.Name {
164
- case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch":
471
+ case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP":
165
472
  default:
166
473
  return true
167
474
  }
168
475
  method := ""
169
- if len(call.Args) > 0 {
170
- switch arg := call.Args[0].(type) {
476
+ index := 0
477
+ if selector.Sel.Name == "HandleHTTP" {
478
+ index = 1
479
+ }
480
+ if len(call.Args) > index {
481
+ switch arg := call.Args[index].(type) {
171
482
  case *ast.BasicLit:
172
483
  if arg.Kind == token.STRING {
173
484
  method, _ = strconv.Unquote(arg.Value)
@@ -181,14 +492,34 @@ func main() {
181
492
  } else if _, declared := in.Permissions[method]; !declared && !sdk[method] {
182
493
  problem(call.Pos(), "registered method missing from permissions.json: "+method)
183
494
  }
495
+ if selector.Sel.Name == "HandleHTTP" {
496
+ prefix := ""
497
+ if len(call.Args) > 0 {
498
+ prefix = stringLiteral(call.Args[0])
499
+ }
500
+ if !regexp.MustCompile(`^/([A-Za-z0-9_-]+/)+$`).MatchString(prefix) {
501
+ problem(call.Pos(), "HTTP prefix must be a fixed literal full-segment path ending in /")
502
+ }
503
+ for _, reserved := range []string{"/_sso/", "/_spl/", "/api/", "/api.cgi/", "/rpc/", "/webman/", "/cgi/"} {
504
+ if strings.HasPrefix(prefix, reserved) || strings.HasPrefix(reserved, prefix) {
505
+ problem(call.Pos(), "HTTP prefix overlaps a reserved path")
506
+ }
507
+ }
508
+ if sdk[method] || in.Permissions[method] != "authenticated" && in.Permissions[method] != "administrators" {
509
+ problem(call.Pos(), "HTTP routes require an explicit App authenticated or administrators permission")
510
+ }
511
+ for _, other := range out.HTTPRoutes {
512
+ if strings.HasPrefix(prefix, other.Prefix) || strings.HasPrefix(other.Prefix, prefix) {
513
+ problem(call.Pos(), "HTTP prefixes must not overlap")
514
+ }
515
+ }
516
+ out.HTTPRoutes = append(out.HTTPRoutes, httpRoute{prefix, method})
517
+ }
184
518
  return true
185
519
  })
186
- return nil
187
- })
188
- if err != nil {
189
- fmt.Fprintln(os.Stderr, err)
190
- os.Exit(1)
191
520
  }
521
+ sort.Strings(out.Problems)
522
+ sort.Slice(out.HTTPRoutes, func(i, j int) bool { return out.HTTPRoutes[i].Prefix < out.HTTPRoutes[j].Prefix })
192
523
  if err := json.NewEncoder(os.Stdout).Encode(out); err != nil {
193
524
  os.Exit(1)
194
525
  }
@@ -33,6 +33,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
33
33
  if (!Array.isArray(methods) || methods.some(item => typeof item.method !== 'string' || typeof item.permission !== 'string'))
34
34
  throw new StandaloneBuildError('CTL_DESCRIBE_FAILED', 'Invalid registry snapshot.');
35
35
  const registry = new Map(methods.map(item => [item.method, item.permission]));
36
+ const rawHTTP = new Set(methods.filter(item => item.kind === 'http').map(item => item.method));
36
37
  // Identity transport routes are outside the business registry but have immutable SDK ownership.
37
38
  if (m.capabilities.identity) {
38
39
  registry.set('identity.session', 'public');
@@ -46,6 +47,8 @@ export async function buildApiDescription(m, bridgeMethods, run) {
46
47
  const names = flowMethods(command.flow);
47
48
  let permission = 'public';
48
49
  for (const name of names) {
50
+ if (rawHTTP.has(name))
51
+ throw new StandaloneBuildError('CTL_COMMAND_UNREGISTERED', `Command ${id} references raw HTTP route ${name}; ctl commands require RPC methods.`);
49
52
  if (!registry.has(name))
50
53
  throw new StandaloneBuildError('CTL_COMMAND_UNREGISTERED', `Command ${id} references unregistered method ${name}.`);
51
54
  const effective = m.entry === 'admin-only' ? 'administrators' : registry.get(name);
@@ -1,5 +1,5 @@
1
1
  import { buildInstallSettings } from './install-settings.js';
2
- import { auditGoRoutes } from './route-audit.js';
2
+ import { auditGoRoutes, httpRoutesDigest } from './route-audit.js';
3
3
  import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
4
4
  import { buildApiDescription } from "./api-description.js";
5
5
  import { goChildEnv } from "../runtime/go-env.js";
@@ -234,7 +234,7 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
234
234
  throw new StandaloneBuildError("STANDALONE_API_PERMISSIONS_CHANGED", "API permissions changed during build; check and rebuild.");
235
235
  }
236
236
  const policy = readApiPermissions(backend, manifest.capabilities);
237
- const audit = auditGoRoutes(backend.dir, policy.policy, policy.sdk);
237
+ const audit = auditGoRoutes(backend.dir, policy.policy, policy.sdk, [backend.main], [goBuildEnv(arch).GOARCH]);
238
238
  if (audit.problems.length || manifest.entry && !audit.entryGate) {
239
239
  throw new StandaloneBuildError("STANDALONE_API_REGISTRY_INVALID", audit.problems.join("\n") || "App entry gate was removed during build.");
240
240
  }
@@ -252,6 +252,8 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
252
252
  `-X ${module}/internal/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
253
253
  `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
254
254
  `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
255
+ `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
256
+ `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
255
257
  ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
256
258
  ...delegateBuildFlags(manifest),
257
259
  ...(manifest.executor
@@ -302,11 +304,20 @@ async function buildBackgroundWorker(manifest, arch, work, run) {
302
304
  if (!manifest.capabilities.jobs || manifest.backend.kind !== "go")
303
305
  return undefined;
304
306
  const out = join(work, `background-worker-${arch}`);
307
+ const policy = readApiPermissions(manifest.backend, manifest.capabilities);
308
+ const audit = auditGoRoutes(manifest.backend.dir, policy.policy, policy.sdk, [BACKGROUND_WORKER_MAIN], [goBuildEnv(arch).GOARCH]);
309
+ if (audit.problems.length)
310
+ throw new StandaloneBuildError("STANDALONE_API_REGISTRY_INVALID", audit.problems.join("\n"));
305
311
  const ldflags = [
306
312
  "-s",
307
313
  "-w",
308
314
  "-buildid=",
309
315
  `-X main.packageID=${manifest.package}`,
316
+ `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
317
+ `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
318
+ `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
319
+ `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
320
+ ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
310
321
  ...delegateBuildFlags(manifest),
311
322
  ...(manifest.executor
312
323
  ? [
@@ -1,5 +1,6 @@
1
1
  import { readInstallSettings } from './install-settings.js';
2
2
  import { auditGoRoutes } from './route-audit.js';
3
+ import { BACKGROUND_WORKER_MAIN } from './background.js';
3
4
  import { isWebLaunchMode } from "../contract/web-launch.js";
4
5
  import { validWebAPIProfiles, } from "../runtime/webapi-profile.js";
5
6
  import { validateMetadata } from "../package-metadata.js";
@@ -354,11 +355,18 @@ function readManifest(raw, c) {
354
355
  }
355
356
  if (backend?.kind === "go" && permissions && sdkVersionOrUndefined(backend) && (raw.entry === undefined || entry)) {
356
357
  try {
357
- const audit = auditGoRoutes(backend.dir, permissions.policy, permissions.sdk);
358
- for (const problem of audit.problems)
358
+ const problems = new Set();
359
+ for (const main of [backend.main, ...(capabilities.jobs ? [BACKGROUND_WORKER_MAIN] : [])]) {
360
+ for (const arch of arches ?? []) {
361
+ const audit = auditGoRoutes(backend.dir, permissions.policy, permissions.sdk, [main], [({ x86_64: 'amd64', aarch64: 'arm64', armv7: 'arm' })[arch]]);
362
+ for (const problem of audit.problems)
363
+ problems.add(problem);
364
+ if (main === backend.main && entry && !audit.entryGate)
365
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "App entry requires an actual dsm.NewEntryGate call; comments do not enforce admission.");
366
+ }
367
+ }
368
+ for (const problem of problems)
359
369
  c.fail("STANDALONE_API_REGISTRY_INVALID", problem);
360
- if (entry && !audit.entryGate)
361
- c.fail("STANDALONE_ENTRY_UNSUPPORTED", "App entry requires an actual dsm.NewEntryGate call; comments do not enforce admission.");
362
370
  }
363
371
  catch (error) {
364
372
  c.fail("STANDALONE_API_REGISTRY_INVALID", error.message);
@@ -1,6 +1,12 @@
1
1
  import type { ApiPermissions } from './api-permissions.js';
2
- /** Parse Go syntax without executing an App's main, init functions or --describe. */
3
- export declare function auditGoRoutes(dir: string, policy: ApiPermissions, sdk: Record<string, Record<string, string>>): {
2
+ export interface RouteAudit {
4
3
  problems: string[];
5
4
  entryGate: boolean;
6
- };
5
+ httpRoutes: {
6
+ prefix: string;
7
+ method: string;
8
+ }[];
9
+ }
10
+ export declare function httpRoutesDigest(audit: RouteAudit): string;
11
+ /** Parse Go syntax without executing an App's main, init functions or --describe. */
12
+ export declare function auditGoRoutes(dir: string, policy: ApiPermissions, sdk: Record<string, Record<string, string>>, roots?: string[], arches?: string[]): RouteAudit;
@@ -1,12 +1,16 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { goChildEnv } from '../runtime/go-env.js';
7
8
  let binary;
9
+ export function httpRoutesDigest(audit) {
10
+ return createHash('sha256').update(JSON.stringify(audit.httpRoutes)).digest('hex');
11
+ }
8
12
  /** Parse Go syntax without executing an App's main, init functions or --describe. */
9
- export function auditGoRoutes(dir, policy, sdk) {
13
+ export function auditGoRoutes(dir, policy, sdk, roots = ['.'], arches = ['amd64', 'arm64', 'arm']) {
10
14
  const env = goChildEnv({ ...process.env, GOOS: undefined, GOARCH: undefined, GOARM: undefined, GOWORK: 'off', CGO_ENABLED: '0' });
11
15
  if (!binary) {
12
16
  const work = mkdtempSync(join(tmpdir(), 'synopkgland-route-audit-'));
@@ -18,7 +22,7 @@ export function auditGoRoutes(dir, policy, sdk) {
18
22
  throw new Error('Cannot build the Go route auditor; install Go and retry.');
19
23
  binary = out;
20
24
  }
21
- const result = spawnSync(binary, [], { env, input: JSON.stringify({ Dir: realpathSync(dir), Permissions: policy.methods, SDK: sdk }), encoding: 'utf8', timeout: 30_000, maxBuffer: 4 << 20 });
25
+ const result = spawnSync(binary, [], { env, input: JSON.stringify({ Dir: realpathSync(dir), Roots: roots, Arches: arches, Permissions: policy.methods, SDK: sdk }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20 });
22
26
  if (result.error || result.status !== 0)
23
27
  throw new Error(`Cannot audit Go routes: ${result.stderr?.trim() || 'auditor unavailable'}`);
24
28
  return JSON.parse(result.stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- CLI 0.33.1 installs templates through `~0.31.0`. A templates patch needs only a
8
+ CLI 0.35.0 installs templates through `~0.32.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -24,12 +24,22 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
24
24
 
25
25
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
26
26
 
27
- Use SDK 0.32.0 and CLI 0.34.0 for App entry enforcement. Declare every App method
28
- in the permissions table; dynamic names and custom HTTP dispatchers are rejected.
27
+ Use SDK 0.33.0 and CLI 0.35.0 for App entry enforcement. Declare every App method
28
+ in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
29
29
  Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
30
30
  paths that rely on upstream authentication. Read
31
31
  [reference/identity.md](reference/identity.md) and
32
32
  [reference/identity-gateway.md](reference/identity-gateway.md) before migration.
33
+ For private worker HTTP, use `ipc.ServeUnix(ctx, path, handler)` and
34
+ `ipc.DialUnix`, never a custom listener. The helper requires an owned mode-0600
35
+ socket under the package's private `var/run`. For browser proxies, streams or
36
+ initial upgrades, register `api.HandleHTTP("/proxy/", "app.proxy", handler)`
37
+ in the same API registry and declare the method as authenticated or
38
+ administrators. Prefixes are literal, disjoint full segments outside reserved
39
+ SDK paths. Origin, entry, identity and permission checks still apply. CGI
40
+ cannot upgrade a connection; use an existing upgrade-capable deployment
41
+ transport. The audit follows each configured Go binary's Linux import graph,
42
+ including declared workers; unshipped dev commands are excluded.
33
43
 
34
44
  **If you package someone else's open-source project, set `maintainer` to the
35
45
  original project/owner (prefer the project's own team/org name, else the owner's