Skip to content

Commit 81237da

Browse files
czprzCopilot
andauthored
#38 Add support for mocking snmp endpoint (#44)
* feat: add SNMP protocol support (issue #38) - Add SNMPv1/v2c/v3 agent mock using GoSNMPServer (GET/GETNEXT/GETBULK/SET) - Add outbound TRAP sending via gosnmp - Add config types: SNMPConfig, SNMPMock, SNMPUser, SNMPTrap, SNMPTrapBinding - Add internal/protocols/snmpserver package with 35 unit+integration tests - Add 7 REST API endpoints: CRUD for OID mocks + trap config + trap send - Wire SNMP server into cmd/mockly/main.go - Update configs/example.yaml with full SNMP section - Update README.md: features table, SNMP protocol section, API reference, architecture diagram - Update docs/openapi.yaml: SNMPMock/SNMPTrap/SNMPUser schemas + 7 endpoint paths - Add 6 E2E tests in tests/e2e/e2e_test.go - Fix data race in restart loop: protect s.srv reads/writes with mutex and capture srv pointer before launching ServeForever goroutine Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(snmp): eliminate double-Shutdown race on mock reload SetMocks was calling srv.Shutdown() concurrently with the Start loop's own current.Shutdown() call in the <-restartCh case, causing a data race inside GoSNMPServer's UDPListener. Fix: SetMocks only closes the restart channel; the Start loop is now the sole owner of Shutdown. The <-restartCh case calls current.Shutdown() before continuing, guaranteeing the UDP port is released before buildAndListen re-binds it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(api): replace nil snmp arg with stubSNMP consistent with other stubs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(api): add SNMP handler tests and make stubSNMP stateful - stubSNMP now holds mocks/traps state and validates IDs in SendTrap - startAPI returns *stubSNMP for inspection in tests - Add TestAPI_SNMP_MockCRUD: list/create/update/delete OID mocks via API - Add TestAPI_SNMP_TrapsCRUD: list/create trap configs via API - Add TestAPI_SNMP_SendTrap: send recorded trap, verify stub receives call - Add TestAPI_SNMP_SendTrap_NotFound: unknown ID returns 500 - Add TestAPI_SNMP_UpdateMock_NotFound: unknown ID returns 404 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(api): update startAPI calls to include additional parameters * docs: fix architecture diagram alignment with SNMP box Expand outer box to 92 chars so all 9 protocol boxes fit in a single aligned row. Fix SNMP box width (was │SNMP│/│1161│ = 4-char content, now │ SNMP │/│:1161 │ = 6-char content, matching HTTP/SMTP/MQTT). Remove duplicate row left over from the initial SNMP addition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 77fbf63 commit 81237da

16 files changed

Lines changed: 2373 additions & 62 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
{
22
"name": "Mockly",
33
"image": "ghcr.io/dever-labs/devcontainers/go-dev:latest",
4-
"postCreateCommand": "GOTOOLCHAIN=auto go install github.com/air-verse/air@v1.61.7 && go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 && npm --prefix ui ci",
54
"mounts": [
65
"source=dever-labs-gh-config,target=/home/vscode/.config/gh,type=volume",
7-
"source=dever-labs-copilot-config,target=/home/vscode/.copilot,type=volume"
6+
"source=dever-labs-copilot,target=/home/vscode/.copilot,type=volume"
87
],
98

109
"remoteEnv": {
@@ -21,19 +20,23 @@
2120
"customizations": {
2221
"vscode": {
2322
"extensions": [
24-
"github.copilot",
25-
"github.copilot-chat",
23+
"GitHub.copilot",
24+
"GitHub.copilot-chat",
2625
"anthropics.claude-code",
27-
"golang.go",
26+
"golang.go"
2827
],
2928
"settings": {
3029
"go.toolsManagement.checkForUpdates": "off",
3130
"go.useLanguageServer": true,
3231
"editor.formatOnSave": true,
32+
"github.copilot.enable": { "*": true },
3333
"[go]": {
3434
"editor.defaultFormatter": "golang.go"
3535
}
3636
}
3737
}
38-
}
38+
},
39+
40+
"postCreateCommand": "bash .devcontainer/init.sh",
41+
"remoteUser": "vscode"
3942
}

.devcontainer/init.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
CYAN='\033[0;36m'
5+
GREEN='\033[0;32m'
6+
YELLOW='\033[1;33m'
7+
NC='\033[0m'
8+
9+
echo -e "${CYAN}==> Installing Go tools...${NC}"
10+
GOTOOLCHAIN=auto go install github.com/air-verse/air@v1.61.7
11+
GOTOOLCHAIN=auto go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8
12+
13+
echo -e "${CYAN}==> Installing UI dependencies...${NC}"
14+
npm --prefix ui ci
15+
16+
echo -e ""
17+
echo -e "${GREEN}✔ Devcontainer ready!${NC}"
18+
echo -e ""
19+
echo -e "Quick-start commands:"
20+
echo -e " ${YELLOW}air${NC} — run the server with hot reload"
21+
echo -e " ${YELLOW}go test ./internal/... -race${NC} — run unit/integration tests"
22+
echo -e " ${YELLOW}golangci-lint run${NC} — lint the code"
23+
echo -e " ${YELLOW}npm --prefix ui run dev${NC} — run the UI in dev mode"
24+
echo -e ""
25+
echo -e "Ports:"
26+
echo -e " HTTP Mocks → http://localhost:8080"
27+
echo -e " WebSocket Mocks → ws://localhost:8081"
28+
echo -e " Management API + UI → http://localhost:9091"
29+
echo -e " gRPC Mocks → localhost:50051"
30+
echo -e ""
31+
32+
if ls ~/.copilot/*.json &>/dev/null 2>&1; then
33+
echo -e "${GREEN}✔ GitHub Copilot CLI authenticated.${NC}"
34+
else
35+
echo -e "Run ${YELLOW}copilot login${NC} to authenticate GitHub Copilot CLI."
36+
echo -e "Credentials persist in a Docker volume — no re-login needed after rebuild."
37+
fi

README.md

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Mockly
22

3-
**Cross-platform, multi-protocol mock server** — HTTP, WebSocket, gRPC, GraphQL, TCP, Redis, SMTP, and MQTT in a single binary with a built-in web UI, REST management API, scenario system, and fault injection.
3+
**Cross-platform, multi-protocol mock server** — HTTP, WebSocket, gRPC, GraphQL, TCP, Redis, SMTP, MQTT, and SNMP in a single binary with a built-in web UI, REST management API, scenario system, and fault injection.
44

55
[![CI](https://github.com/dever-labs/mockly/actions/workflows/ci.yml/badge.svg)](https://github.com/dever-labs/mockly/actions/workflows/ci.yml)
66
[![Latest release](https://img.shields.io/github/v/release/dever-labs/mockly)](https://github.com/dever-labs/mockly/releases/latest)
@@ -34,7 +34,7 @@
3434

3535
| Feature | Details |
3636
|---|---|
37-
| **Protocols** | HTTP, WebSocket, gRPC, GraphQL, TCP, Redis, SMTP, MQTT |
37+
| **Protocols** | HTTP, WebSocket, gRPC, GraphQL, TCP, Redis, SMTP, MQTT, SNMP |
3838
| **Request matching** | Method + path (exact / wildcard / regex), headers, query params, JSON body fields |
3939
| **Response sequences** | Return a different response on each successive call — loop, hold last, or 404 when exhausted |
4040
| **Response control** | Status code, headers, body, artificial delay |
@@ -506,6 +506,64 @@ Topic wildcards: `+` matches a single segment, `#` matches everything below.
506506

507507
---
508508

509+
### SNMP
510+
511+
Full SNMP agent (powered by GoSNMPServer) that responds to GET, GETNEXT, GETBULK, and SET requests. Supports SNMPv1, v2c, and v3 (USM with MD5/SHA auth and DES/AES privacy). Can also send outbound TRAPs to any target host via the management API.
512+
513+
```yaml
514+
protocols:
515+
snmp:
516+
enabled: true
517+
port: 1161 # default; 161 requires root / CAP_NET_BIND_SERVICE
518+
community: "public" # v1/v2c community string
519+
v3_users:
520+
- username: mocklyuser
521+
auth_protocol: md5 # md5 | sha | sha224 | sha256 | sha384 | sha512
522+
auth_passphrase: mocklyauth
523+
priv_protocol: des # des | aes | aes192 | aes256
524+
priv_passphrase: mocklypriv
525+
mocks:
526+
- id: sys-descr
527+
oid: 1.3.6.1.2.1.1.1.0
528+
type: string
529+
value: "Mockly Virtual Device"
530+
- id: sys-uptime
531+
oid: 1.3.6.1.2.1.1.3.0
532+
type: timeticks
533+
value: 987654
534+
- id: if-number
535+
oid: 1.3.6.1.2.1.2.1.0
536+
type: integer
537+
value: 4
538+
traps:
539+
- id: cold-start
540+
target: "127.0.0.1:1162"
541+
version: "2c"
542+
community: "public"
543+
oid: 1.3.6.1.6.3.1.1.5.1
544+
bindings:
545+
- oid: 1.3.6.1.2.1.1.1.0
546+
type: string
547+
value: "Device restarted"
548+
```
549+
550+
**Supported OID types:**
551+
552+
| `type` value | SNMP ASN.1 type | Example `value` |
553+
|---|---|---|
554+
| `string` / `octetstring` | OctetString | `"Mockly Virtual Device"` |
555+
| `integer` / `int` | Integer | `42` |
556+
| `gauge32` | Gauge32 | `100` |
557+
| `counter32` | Counter32 | `1048576` |
558+
| `counter64` | Counter64 | `9000000000` |
559+
| `timeticks` | TimeTicks | `987654` |
560+
| `ipaddress` | IPAddress | `"192.168.1.1"` |
561+
| `objectidentifier` / `oid` | ObjectIdentifier | `"1.3.6.1.2.1.1.2.0"` |
562+
563+
**TRAP sending** — POST to `/api/snmp/traps/{id}/send` to trigger any configured trap. The agent connects to the trap's `target` over UDP and sends the PDU.
564+
565+
---
566+
509567
## Component Testing
510568

511569
Mockly is designed for **component testing** — testing how your application behaves when a dependency returns errors, timeouts, unexpected data, or edge-case responses. The config file is owned by the dependency team; consuming teams just load it and toggle scenarios.
@@ -788,7 +846,7 @@ Base URL: `http://localhost:9091`
788846
| `PATCH` | `/api/mocks/http/{id}` | Partial update HTTP mock |
789847
| `DELETE` | `/api/mocks/http/{id}` | Delete HTTP mock |
790848

791-
Similarly for WebSocket (`/api/mocks/websocket`), gRPC (`/api/mocks/grpc`), GraphQL (`/api/mocks/graphql`), TCP (`/api/mocks/tcp`), Redis (`/api/mocks/redis`), SMTP (`/api/mocks/smtp`), MQTT (`/api/mocks/mqtt`).
849+
Similarly for WebSocket (`/api/mocks/websocket`), gRPC (`/api/mocks/grpc`), GraphQL (`/api/mocks/graphql`), TCP (`/api/mocks/tcp`), Redis (`/api/mocks/redis`), SMTP (`/api/mocks/smtp`), MQTT (`/api/mocks/mqtt`), and SNMP (`/api/mocks/snmp`).
792850

793851
### Call Verification (HTTP)
794852

@@ -813,6 +871,18 @@ Similarly for WebSocket (`/api/mocks/websocket`), gRPC (`/api/mocks/grpc`), Grap
813871
| `GET` | `/api/mqtt/messages` | List captured MQTT messages |
814872
| `DELETE` | `/api/mqtt/messages` | Clear message store |
815873

874+
### SNMP Mocks & Traps
875+
876+
| Method | Path | Description |
877+
|---|---|---|
878+
| `GET` | `/api/mocks/snmp` | List configured OID mocks |
879+
| `POST` | `/api/mocks/snmp` | Add an OID mock |
880+
| `PUT` | `/api/mocks/snmp/{id}` | Replace an OID mock |
881+
| `DELETE` | `/api/mocks/snmp/{id}` | Remove an OID mock |
882+
| `GET` | `/api/snmp/traps` | List configured traps |
883+
| `POST` | `/api/snmp/traps` | Add a trap config |
884+
| `POST` | `/api/snmp/traps/{id}/send` | Send a configured trap immediately |
885+
816886
### Scenarios
817887

818888
| Method | Path | Description |
@@ -1096,21 +1166,21 @@ docker compose up
10961166
## Architecture
10971167

10981168
```
1099-
┌──────────────────────────────────────────────────────────────────────────────────┐
1100-
│ Single Binary │
1101-
│ │
1102-
│ ┌─────────────────────────────────────────────────────────────────────┐ │
1103-
│ │ Management API + Web UI :9091 │ │
1104-
│ │ CRUD mocks/rules · scenarios · fault · state · logs/SSE │ │
1105-
│ └─────────────────────────────────────────────────────────────────────┘ │
1106-
│ │
1107-
│ ┌──────┐ ┌─────────┐ ┌──────┐ ┌─────────┐ ┌─────┐ ┌───────┐ ┌──────┐ ┌──────┐ │
1108-
│ │ HTTP │ │WebSocket│ │ gRPC │ │GraphQL │ │ TCP │ │ Redis │ │ SMTP │ │ MQTT │ │
1109-
│ │:8080 │ │ :8081 │ │:50051│ │ :8082 │ │:8083│ │ :6379 │ │:2525 │ │:1883 │ │
1110-
│ └──────┘ └─────────┘ └──────┘ └─────────┘ └─────┘ └───────┘ └──────┘ └──────┘ │
1111-
│ │
1112-
│ Shared: State Store · Request Logger · Scenario Store │
1113-
└──────────────────────────────────────────────────────────────────────────────────┘
1169+
┌──────────────────────────────────────────────────────────────────────────────────────────
1170+
Single Binary
1171+
1172+
│ ┌─────────────────────────────────────────────────────────────────────┐
1173+
│ │ Management API + Web UI :9091 │
1174+
│ │ CRUD mocks/rules · scenarios · fault · state · logs/SSE │
1175+
│ └─────────────────────────────────────────────────────────────────────┘
1176+
1177+
│ ┌──────┐ ┌─────────┐ ┌──────┐ ┌─────────┐ ┌─────┐ ┌───────┐ ┌──────┐ ┌──────┐ ┌──────┐
1178+
│ │ HTTP │ │WebSocket│ │ gRPC │ │GraphQL │ │ TCP │ │ Redis │ │ SMTP │ │ MQTT │ │ SNMP │
1179+
│ │:8080 │ │ :8081 │ │:50051│ │ :8082 │ │:8083│ │ :6379 │ │:2525 │ │:1883 │ │:1161 │
1180+
│ └──────┘ └─────────┘ └──────┘ └─────────┘ └─────┘ └───────┘ └──────┘ └──────┘ └──────┘
1181+
1182+
│ Shared: State Store · Request Logger · Scenario Store
1183+
└──────────────────────────────────────────────────────────────────────────────────────────
11141184
```
11151185
11161186
---

cmd/mockly/main.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/dever-labs/mockly/internal/protocols/mqttserver"
2424
"github.com/dever-labs/mockly/internal/protocols/redisserver"
2525
"github.com/dever-labs/mockly/internal/protocols/smtpserver"
26+
"github.com/dever-labs/mockly/internal/protocols/snmpserver"
2627
"github.com/dever-labs/mockly/internal/protocols/tcpserver"
2728
"github.com/dever-labs/mockly/internal/protocols/wsserver"
2829
"github.com/dever-labs/mockly/internal/scenarios"
@@ -111,6 +112,7 @@ func runServers(cfg *config.Config) error {
111112
var redisSrv api.RedisProtocol
112113
var smtpSrv api.SMTPProtocol
113114
var mqttSrv api.MQTTProtocol
115+
var snmpSrv api.SNMPProtocol
114116

115117
if cfg.Protocols.HTTP != nil && cfg.Protocols.HTTP.Enabled {
116118
srv := httpserver.New(cfg.Protocols.HTTP, store, sc, log)
@@ -168,7 +170,14 @@ func runServers(cfg *config.Config) error {
168170
fmt.Printf("→ MQTT broker on :%d\n", cfg.Protocols.MQTT.Port)
169171
}
170172

171-
apiSrv := api.New(cfg, store, sc, log, httpSrv, wsSrv, grpcSrv, graphqlSrv, tcpSrv, redisSrv, smtpSrv, mqttSrv)
173+
if cfg.Protocols.SNMP != nil && cfg.Protocols.SNMP.Enabled {
174+
srv := snmpserver.New(cfg.Protocols.SNMP, store, log)
175+
snmpSrv = srv
176+
go func() { errCh <- srv.Start(ctx) }()
177+
fmt.Printf("→ SNMP agent on :%d\n", cfg.Protocols.SNMP.Port)
178+
}
179+
180+
apiSrv := api.New(cfg, store, sc, log, httpSrv, wsSrv, grpcSrv, graphqlSrv, tcpSrv, redisSrv, smtpSrv, mqttSrv, snmpSrv)
172181

173182
if cfg.Mockly.UI.Enabled {
174183
apiSrv.AttachUI(assets.DistFS())

configs/example.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,54 @@ protocols:
274274
payload: '{"ok":true}'
275275
qos: 1
276276

277+
# ─── SNMP ───────────────────────────────────────────────────────────────────
278+
snmp:
279+
enabled: true
280+
port: 1161 # use 161 for the standard port (requires root/CAP_NET_BIND_SERVICE)
281+
community: "public" # v1/v2c community string accepted by the agent
282+
v3_users:
283+
- username: mocklyuser
284+
auth_protocol: md5 # md5 | sha | sha224 | sha256 | sha384 | sha512
285+
auth_passphrase: mocklyauth
286+
priv_protocol: des # des | aes | aes192 | aes256
287+
priv_passphrase: mocklypriv
288+
mocks:
289+
- id: sys-descr
290+
oid: 1.3.6.1.2.1.1.1.0
291+
type: string
292+
value: "Mockly Virtual Device"
293+
294+
- id: sys-uptime
295+
oid: 1.3.6.1.2.1.1.3.0
296+
type: timeticks
297+
value: 987654
298+
299+
- id: if-number
300+
oid: 1.3.6.1.2.1.2.1.0
301+
type: integer
302+
value: 4
303+
304+
- id: in-octets
305+
oid: 1.3.6.1.2.1.2.2.1.10.1
306+
type: counter32
307+
value: 1048576
308+
309+
- id: mgmt-ip
310+
oid: 1.3.6.1.4.1.9999.1.0
311+
type: ipaddress
312+
value: "10.0.0.1"
313+
314+
traps:
315+
- id: cold-start
316+
target: "127.0.0.1:1162"
317+
version: "2c"
318+
community: "public"
319+
oid: 1.3.6.1.6.3.1.1.5.1 # coldStart
320+
bindings:
321+
- oid: 1.3.6.1.2.1.1.1.0
322+
type: string
323+
value: "Device restarted"
324+
277325
# ─── Scenarios ──────────────────────────────────────────────────────────────
278326
# Pre-defined failure modes; activate/deactivate via CLI or Management API.
279327
scenarios:

0 commit comments

Comments
 (0)