Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.2"
go-version: "1.26.6"
cache: true

- name: Build all packages
Expand All @@ -46,7 +46,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.2"
go-version: "1.26.6"
cache: true

- name: Module static tests
Expand All @@ -71,7 +71,58 @@ jobs:
if: always()
run: |
echo "## Go test" >> "$GITHUB_STEP_SUMMARY"
echo "Commands: \`make test-modules-static\`, \`go test ./test/...\`, coverage gate 90%" >> "$GITHUB_STEP_SUMMARY"
echo "Commands: \`make test-modules-static\`, \`go test ./test/...\`, coverage gate ${GO_COVERAGE_MIN:-42}%" >> "$GITHUB_STEP_SUMMARY"

go-lint:
name: Go lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Needed for golangci-lint only-new-issues on pull_request.
fetch-depth: 0

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
cache: true

- name: go vet
run: go vet ./...

- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# Need a v2.x build that supports analyzing Go 1.26 modules.
version: latest
args: --timeout=10m
only-new-issues: ${{ github.event_name == 'pull_request' }}

- name: Job summary
if: always()
run: |
echo "## Go lint" >> "$GITHUB_STEP_SUMMARY"
echo "Commands: \`go vet ./...\`, \`golangci-lint run\`" >> "$GITHUB_STEP_SUMMARY"

go-vuln:
name: Go vulnerabilities
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-go@v7
with:
go-version: "1.26.6"
cache: true

- name: govulncheck
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...

- name: Job summary
if: always()
run: |
echo "## Go vulnerabilities" >> "$GITHUB_STEP_SUMMARY"
echo "Command: \`govulncheck ./...\`" >> "$GITHUB_STEP_SUMMARY"

swc:
name: SWC check and test
Expand All @@ -84,7 +135,7 @@ jobs:

- uses: actions/setup-node@v7
with:
node-version: "22"
node-version: "24"
cache: npm
cache-dependency-path: core/swc/package-lock.json

Expand All @@ -111,7 +162,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.2"
go-version: "1.26.6"
cache: true

- name: Regenerate zimports.go
Expand All @@ -135,7 +186,7 @@ jobs:
# runs-on: ubuntu-latest
# services:
# postgres:
# image: postgres:16-alpine
# image: postgres:18-alpine
# env:
# POSTGRES_USER: postgres
# POSTGRES_PASSWORD: postgres
Expand All @@ -152,7 +203,7 @@ jobs:

# - uses: actions/setup-go@v7
# with:
# go-version: "1.26.2"
# go-version: "1.26.6"
# cache: true

# - name: Bootstrap kernel schema
Expand Down
30 changes: 30 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
version: "2"

linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
settings:
errcheck:
# Deferred Close errors are rarely actionable; keep errcheck for real ignored returns.
exclude-functions:
- (io.Closer).Close
- (*database/sql.Rows).Close
- (*database/sql.DB).Close
- (*os.File).Close
- (*compress/gzip.Reader).Close
- (*mime/multipart.FileHeader).Open
- (net/smtp.Client).Close
- (*net/smtp.Client).Close
- (*github.com/gorilla/websocket.Conn).Close

run:
timeout: 10m

issues:
max-issues-per-linter: 0
max-same-issues: 0
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Multi-stage production image for the Sumeru engine (single-node pilot).
FROM golang:1.26.6-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/sumeru ./cmd/sumeru

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/sumeru /app/sumeru
COPY --from=build /src/addons /app/addons
COPY --from=build /src/core/engine/assets /app/core/engine/assets
COPY --from=build /src/core/engine/templates /app/core/engine/templates
COPY --from=build /src/sumeru.conf.example /app/sumeru.conf
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/sumeru", "-c", "/app/sumeru.conf"]
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: help setup dev build css run generate bp check-sql check-logs db-check \
i18n-export i18n-import module shell test-db test-integration test-coverage \
test-modules test-modules-static test-modules-unit test-modules-addon test-modules-integration \
swc swc-build assets swc-check swc-test check
swc swc-build assets swc-check swc-test check lint

# Extra flags for `make run`, e.g. `make run EXTRA_RUN_FLAGS='-p 9090 -d sumeru_staging'`
EXTRA_RUN_FLAGS ?=
Expand All @@ -19,6 +19,12 @@ check-sql:
check-logs:
@bash scripts/check_no_stdlog.sh

# Match CI go-lint: go vet + golangci-lint v2 (see .golangci.yml).
# Use go run so a stale v1 binary on PATH does not break the target.
lint:
go vet ./...
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run --timeout=10m

generate:
go generate ./cmd/sumeru

Expand Down Expand Up @@ -62,7 +68,7 @@ dev: run
build: generate assets
go build -o sumeru ./cmd/sumeru

check: swc-check test-modules-static
check: swc-check lint test-modules-static
go test ./test/... -count=1

test-modules-static:
Expand Down Expand Up @@ -127,12 +133,13 @@ help:
@echo "Go / addons:"
@echo " make generate - refresh cmd/sumeru/zimports.go"
@echo " make bp NAME=x - scaffold kernel addon (then make generate)"
@echo " make check - swc-check + test-modules-static + go test ./test/..."
@echo " make lint - go vet + golangci-lint (matches CI go-lint)"
@echo " make check - swc-check + lint + test-modules-static + go test ./test/..."
@echo " make test-modules - static + unit + addon module suite tiers"
@echo " make test-coverage - full repo coverage with 90% gate"
@echo " make module - module CLI (ARGS='list' | 'install sales' | ...)"
@echo " make shell - ORM REPL"
@echo ""
@echo "Other: db-check | i18n-export | i18n-import | test-integration | check-sql | check-logs"
@echo "Vars: EXTRA_RUN_FLAGS='-p 9090 -d mydb'"
@echo "Prerequisites: Go 1.26.2+, Node.js (npm), PostgreSQL — see README.md"
@echo "Prerequisites: Go 1.26.6+, Node.js (npm), PostgreSQL — see README.md"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Modular open-source ERP — Go backend, PostgreSQL, and a modern web workspace.**

[![CI](https://github.com/ProjectMeru/sumeru/actions/workflows/ci.yml/badge.svg)](https://github.com/ProjectMeru/sumeru/actions/workflows/ci.yml)
[![Go](https://img.shields.io/badge/Go-1.26.2+-00ADD8?logo=go&logoColor=white)](https://go.dev/dl/)
[![Go](https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white)](https://go.dev/dl/)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Pre-Alpha](https://img.shields.io/badge/Status-Pre--Alpha-critical)](https://github.com/ProjectMeru/sumeru)
[![Docs](https://img.shields.io/badge/Docs-projectmeru.github.io-informational)](https://projectmeru.github.io/sumeru/docs/)
Expand Down Expand Up @@ -38,7 +38,7 @@ This repository is the **core engine** (`module sumeru`). Most teams keep it pul

## Quick start

**Prerequisites:** [Go 1.26.2+](https://go.dev/dl/), [Node.js](https://nodejs.org/) (npm — builds the SWC UI), [PostgreSQL](https://www.postgresql.org/)
**Prerequisites:** [Go 1.26.6+](https://go.dev/dl/), [Node.js](https://nodejs.org/) (npm — builds the SWC UI), [PostgreSQL](https://www.postgresql.org/)

Clone the three sibling repositories, configure the workspace, and run:

Expand Down Expand Up @@ -152,7 +152,7 @@ sumeru_custom_addons

| Layer | Technology |
| ----- | ---------- |
| Server | Go 1.26.2+, structured logging (`log/slog`) |
| Server | Go 1.26.6+, structured logging (`log/slog`) |
| Database | PostgreSQL |
| Modules | Go addons + XML views/menus + manifest sync |
| Workspace UI | SWC (TypeScript) — sources in `core/swc/` |
Expand Down
107 changes: 85 additions & 22 deletions addons/automation/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,33 @@ func dispatchWebhook(ctx context.Context, rawURL string, ev event.Event) error {
if err != nil {
return err
}
u, err := url.Parse(rawURL)
if err != nil {
return err
}
dialIPs, err := resolveWebhookDialIPs(u.Hostname())
if err != nil {
metrics.Inc("sumeru_webhook_blocked_total")
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
DialContext: pinnedWebhookDialer(dialIPs),
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("webhook redirect limit exceeded")
}
return validateWebhookURL(req.URL.String())
if err := validateWebhookURL(req.URL.String()); err != nil {
return err
}
return nil
},
}
resp, err := client.Do(req)
Expand All @@ -68,6 +83,59 @@ func dispatchWebhook(ctx context.Context, rawURL string, ev event.Event) error {
return nil
}

func pinnedWebhookDialer(allowed []net.IP) func(ctx context.Context, network, addr string) (net.Conn, error) {
allowedSet := make(map[string]struct{}, len(allowed))
for _, ip := range allowed {
allowedSet[ip.String()] = struct{}{}
}
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ip := net.ParseIP(host)
if ip == nil {
return nil, fmt.Errorf("webhook dial host must be IP")
}
if _, ok := allowedSet[ip.String()]; !ok {
return nil, fmt.Errorf("webhook dial IP not in validated set")
}
if blockedWebhookIP(ip) {
return nil, fmt.Errorf("webhook dial IP not allowed")
}
var d net.Dialer
return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
}

func resolveWebhookDialIPs(host string) ([]net.IP, error) {
host = strings.TrimSpace(host)
if host == "" {
return nil, fmt.Errorf("webhook host required")
}
if ip := net.ParseIP(host); ip != nil {
if blockedWebhookIP(ip) {
return nil, fmt.Errorf("webhook IP not allowed")
}
return []net.IP{ip}, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("webhook host lookup: %w", err)
}
var out []net.IP
for _, ip := range ips {
if blockedWebhookIP(ip) {
return nil, fmt.Errorf("webhook host resolves to blocked address")
}
out = append(out, ip)
}
if len(out) == 0 {
return nil, fmt.Errorf("webhook host resolved to no addresses")
}
return out, nil
}

func validateWebhookURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
Expand All @@ -89,32 +157,27 @@ func validateWebhookURL(raw string) error {
if lowerHost == "localhost" || strings.HasSuffix(lowerHost, ".localhost") || lowerHost == "metadata.google.internal" {
return fmt.Errorf("webhook host not allowed")
}
if ip := net.ParseIP(host); ip != nil {
if blockedWebhookIP(ip) {
return fmt.Errorf("webhook IP not allowed")
}
return nil
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("webhook host lookup: %w", err)
}
if len(ips) == 0 {
return fmt.Errorf("webhook host resolved to no addresses")
}
for _, ip := range ips {
if blockedWebhookIP(ip) {
return fmt.Errorf("webhook host resolves to blocked address")
}
}
return nil
_, err = resolveWebhookDialIPs(host)
return err
}

func blockedWebhookIP(ip net.IP) bool {
return ip.IsLoopback() ||
if ip == nil {
return true
}
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast()
ip.IsMulticast() {
return true
}
// CGNAT / shared address space (RFC 6598) — not covered by IsPrivate().
if ip4 := ip.To4(); ip4 != nil {
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
return true
}
}
return false
}
9 changes: 0 additions & 9 deletions addons/mail/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,6 @@ func firstCompanyMailSettings(ctx context.Context) (companyMailSettings, bool) {
return out, id.Valid
}

// firstCompanyID returns the primary company row id, or 0 if none.
func firstCompanyID(ctx context.Context) int64 {
settings, ok := firstCompanyMailSettings(ctx)
if !ok {
return 0
}
return settings.id
}

// CompanyChatterEnabled reads mail_chatter_enabled from the first core.company row (default true).
func CompanyChatterEnabled(ctx context.Context) bool {
settings, _ := firstCompanyMailSettings(ctx)
Expand Down
Loading