├── .cirrus.yml
├── .github
├── FUNDING.yml
└── workflows
│ ├── codeql-analysis.yml
│ ├── snyk.yml
│ └── sonarqube.yml
├── .gitignore
├── .idea
├── .gitignore
├── inspectionProfiles
│ └── Project_Default.xml
├── modules.xml
├── postfix-policy-server.iml
└── vcs.xml
├── LICENSE
├── README.md
├── example-code
└── echo-server
│ └── main.go
├── go.mod
├── go.sum
├── pps.go
├── pps_test.go
└── sonar-project.properties
/.cirrus.yml:
--------------------------------------------------------------------------------
1 | container:
2 | image: golang:latest
3 |
4 | env:
5 | GOPROXY: https://proxy.golang.org
6 |
7 | lint_task:
8 | name: GolangCI Lint
9 | container:
10 | image: golangci/golangci-lint:latest
11 | run_script: golangci-lint run -v --timeout 5m0s --out-format json > lint-report.json
12 | always:
13 | golangci_artifacts:
14 | path: lint-report.json
15 | type: text/json
16 | format: golangci
17 |
18 | build_task:
19 | modules_cache:
20 | folder: $GOPATH/pkg/mod
21 | get_script: go get github.com/wneessen/postfix-policy-server
22 | build_script: go build github.com/wneessen/postfix-policy-server
23 | test_script: go test github.com/wneessen/postfix-policy-server
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: wneessen
2 | ko_fi: winni
3 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ main ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ main ]
20 | schedule:
21 | - cron: '35 1 * * 5'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'go' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more:
38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v2
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v1
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
53 |
54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55 | # If this step fails, then you should remove it and run the build manually (see below)
56 | - name: Autobuild
57 | uses: github/codeql-action/autobuild@v1
58 |
59 | # ℹ️ Command-line programs to run using the OS shell.
60 | # 📚 https://git.io/JvXDl
61 |
62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63 | # and modify them (or add more) to build your code if your project
64 | # uses a compiled language
65 |
66 | #- run: |
67 | # make bootstrap
68 | # make release
69 |
70 | - name: Perform CodeQL Analysis
71 | uses: github/codeql-action/analyze@v1
72 |
--------------------------------------------------------------------------------
/.github/workflows/snyk.yml:
--------------------------------------------------------------------------------
1 | on: [workflow_dispatch, push, pull_request_target]
2 | name: Snyk security
3 | jobs:
4 | security:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v2
8 | - name: Run Snyk to check for vulnerabilities
9 | uses: snyk/actions/golang@master
10 | env:
11 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
12 |
--------------------------------------------------------------------------------
/.github/workflows/sonarqube.yml:
--------------------------------------------------------------------------------
1 | name: SonarQube
2 | on:
3 | push:
4 | branches:
5 | - main # or the name of your main branch
6 | jobs:
7 | build:
8 | name: Build
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | with:
13 | fetch-depth: 0
14 |
15 | - name: Setup Go
16 | uses: actions/setup-go@v2.1.3
17 | with:
18 | go-version: 1.18.x
19 |
20 | - name: Run unit Tests
21 | run: |
22 | go test -v -race --coverprofile=./cov.out ./...
23 |
24 | - name: Run Gosec Security Scanner
25 | uses: securego/gosec@master
26 | with:
27 | args: '-no-fail -fmt sonarqube -out report.json ./...'
28 |
29 | - uses: sonarsource/sonarqube-scan-action@master
30 | env:
31 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
32 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
33 | # If you wish to fail your job when the Quality Gate is red, uncomment the
34 | # following lines. This would typically be used to fail a deployment.
35 | - uses: sonarsource/sonarqube-quality-gate-action@master
36 | timeout-minutes: 5
37 | env:
38 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/postfix-policy-server.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Winni Neessen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # postfix-policy-server
2 | [](https://pkg.go.dev/github.com/wneessen/postfix-policy-server)
3 | [](https://goreportcard.com/report/github.com/wneessen/postfix-policy-server)
4 | [](https://cirrus-ci.com/github/wneessen/postfix-policy-server)
5 | [](https://pps-docs.pebcak.de/)
6 |
7 |
8 | **postfix-policy-server** (or short: **pps**) provides a simple framework to create
9 | [Postfix SMTP Access Policy Delegation Servers](http://www.postfix.org/SMTPD_POLICY_README.html) Servers in Go.
10 |
11 | The [pps documentation](https://pps-docs.pebcak.de/) provides you with all you need, to quickly get started
12 | with your own Postifx policy service.
13 |
14 | Alternatively check out the [Go reference](https://pkg.go.dev/github.com/wneessen/postfix-policy-server) for further
15 | details or have a look at the example [echo-server](example-code/echo-server) that is provided with this package.
--------------------------------------------------------------------------------
/example-code/echo-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | /*
4 | This code example implements a simple echo server using the postfix-policy-server framework.
5 | It creates a new policy server that listens for incoming policy requests from postfix on
6 | the default port (10005) and returns a JSON representation of the received PolicySet{} values
7 | to STDOUT
8 |
9 | To integrate this test server with your postfix configuration, you simply have to add
10 | "check_policy_service inet:127.0.0.1:10005" to the "smtpd_recipient_restrictions" of
11 | your postfix' main.cf and reload postfix.
12 |
13 | Example:
14 |
15 | smtpd_recipient_restrictions =
16 | [...]
17 | reject_unauth_destination
18 | check_policy_service inet:127.0.0.1:10005
19 | [...]
20 | */
21 |
22 | import (
23 | "context"
24 | "encoding/json"
25 | "fmt"
26 | "github.com/wneessen/postfix-policy-server"
27 | "log"
28 | )
29 |
30 | // Hi is an empty struct to work as the Handler interface
31 | type Hi struct{}
32 |
33 | // Handle is the test handler for the test server as required by the Handler interface
34 | func (h Hi) Handle(ps *pps.PolicySet) pps.PostfixResp {
35 | log.Println("received new policy set...")
36 | jps, err := json.Marshal(ps)
37 | if err != nil {
38 | log.Printf("failed to marshal policy set data: %s", err)
39 | return pps.RespWarn
40 | }
41 | fmt.Println(string(jps))
42 | return pps.TextResponseOpt(pps.RespInfo, "this might be a cool mail!")
43 | }
44 |
45 | // main starts the server
46 | func main() {
47 | s := pps.New()
48 | ctx, cancel := context.WithCancel(context.Background())
49 | defer cancel()
50 | h := Hi{}
51 | log.Println("Starting policy echo server...")
52 | if err := s.Run(ctx, h); err != nil {
53 | log.Fatalf("could not run server: %s", err)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/wneessen/postfix-policy-server
2 |
3 | go 1.17
4 |
5 | require github.com/rs/xid v1.4.0
6 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
2 | github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
3 |
--------------------------------------------------------------------------------
/pps.go:
--------------------------------------------------------------------------------
1 | package pps
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "log"
8 | "net"
9 | "os"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | "github.com/rs/xid"
15 | )
16 |
17 | // DefaultAddr is the default address the server is listening on
18 | const DefaultAddr = "0.0.0.0"
19 |
20 | // DefaultPort is the default port the server is listening on
21 | const DefaultPort = "10005"
22 |
23 | // CtxKey represents the different key ids for values added to contexts
24 | type CtxKey int
25 |
26 | const (
27 | // ctxConnId represents the connection id in the connection context
28 | ctxConnId CtxKey = iota
29 |
30 | // CtxNoLog lets the user control wether the server should log to
31 | // STDERR or not
32 | CtxNoLog
33 | )
34 |
35 | // PostfixResp is a possible response value for the policy request
36 | type PostfixResp string
37 |
38 | // Possible responses to the postfix server
39 | // See: http://www.postfix.org/access.5.html
40 | const (
41 | RespOk PostfixResp = "OK"
42 | RespReject PostfixResp = "REJECT"
43 | RespDefer PostfixResp = "DEFER"
44 | RespDeferIfReject PostfixResp = "DEFER_IF_REJECT"
45 | RespDeferIfPermit PostfixResp = "DEFER_IF_PERMIT"
46 | RespDiscard PostfixResp = "DISCARD"
47 | RespDunno PostfixResp = "DUNNO"
48 | RespHold PostfixResp = "HOLD"
49 | RespInfo PostfixResp = "INFO"
50 | RespWarn PostfixResp = "WARN"
51 | )
52 |
53 | // PostfixTextResp is a possible response value that requires additional text
54 | type PostfixTextResp string
55 |
56 | // Possible non-optional text responses to the postfix server
57 | const (
58 | TextRespFilter PostfixTextResp = "FILTER"
59 | TextRespPrepend PostfixTextResp = "PREPEND"
60 | TextRespRedirect PostfixTextResp = "REDIRECT"
61 | )
62 |
63 | // polSetFuncs is a map of polSetFunc that assigns a given value to a PolicySet
64 | // See http://www.postfix.org/SMTPD_POLICY_README.html for all supported values
65 | var polSetFuncs = map[string]polSetFunc{
66 | "request": func(ps *PolicySet, v string) { ps.Request = v },
67 | "protocol_state": func(ps *PolicySet, v string) { ps.ProtocolState = v },
68 | "protocol_name": func(ps *PolicySet, v string) { ps.ProtocolName = v },
69 | "helo_name": func(ps *PolicySet, v string) { ps.HELOName = v },
70 | "queue_id": func(ps *PolicySet, v string) { ps.QueueId = v },
71 | "sender": func(ps *PolicySet, v string) { ps.Sender = v },
72 | "recipient": func(ps *PolicySet, v string) { ps.Recipient = v },
73 | "recipient_count": func(ps *PolicySet, v string) {
74 | rc, err := strconv.ParseUint(v, 10, 64)
75 | if err == nil {
76 | ps.RecipientCount = rc
77 | }
78 | },
79 | "client_address": func(ps *PolicySet, v string) {
80 | ca := net.ParseIP(v)
81 | ps.ClientAddress = ca
82 | },
83 | "client_name": func(ps *PolicySet, v string) { ps.ClientName = v },
84 | "reverse_client_name": func(ps *PolicySet, v string) { ps.ReverseClientName = v },
85 | "instance": func(ps *PolicySet, v string) { ps.Instance = v },
86 | "sasl_method": func(ps *PolicySet, v string) { ps.SASLMethod = v },
87 | "sasl_username": func(ps *PolicySet, v string) { ps.SASLUsername = v },
88 | "sasl_sender": func(ps *PolicySet, v string) { ps.SASLSender = v },
89 | "size": func(ps *PolicySet, v string) {
90 | s, err := strconv.ParseUint(v, 10, 64)
91 | if err == nil {
92 | ps.Size = s
93 | }
94 | },
95 | "ccert_subject": func(ps *PolicySet, v string) { ps.CCertSubject = v },
96 | "ccert_issuer": func(ps *PolicySet, v string) { ps.CCertIssuer = v },
97 | "ccert_fingerprint": func(ps *PolicySet, v string) { ps.CCertFingerprint = v },
98 | "encryption_protocol": func(ps *PolicySet, v string) { ps.EncryptionProtocol = v },
99 | "encryption_cipher": func(ps *PolicySet, v string) { ps.EncryptionCipher = v },
100 | "encryption_keysize": func(ps *PolicySet, v string) {
101 | ks, err := strconv.ParseUint(v, 10, 64)
102 | if err == nil {
103 | ps.EncryptionKeysize = ks
104 | }
105 | },
106 | "etrn_domain": func(ps *PolicySet, v string) { ps.ETRNDomain = v },
107 | "stress": func(ps *PolicySet, v string) { ps.Stress = v == "yes" },
108 | "ccert_pubkey_fingerprint": func(ps *PolicySet, v string) { ps.CCertPubkeyFingerprint = v },
109 | "client_port": func(ps *PolicySet, v string) {
110 | cp, err := strconv.ParseUint(v, 10, 64)
111 | if err == nil {
112 | ps.ClientPort = cp
113 | }
114 | },
115 | "policy_context": func(ps *PolicySet, v string) { ps.PolicyContext = v },
116 | "server_address": func(ps *PolicySet, v string) {
117 | sa := net.ParseIP(v)
118 | ps.ServerAddress = sa
119 | },
120 | "server_port": func(ps *PolicySet, v string) {
121 | sp, err := strconv.ParseUint(v, 10, 64)
122 | if err == nil {
123 | ps.ServerPort = sp
124 | }
125 | },
126 | }
127 |
128 | // PolicySet is a set information provided by the postfix policyd request
129 | type PolicySet struct {
130 | // Postfix version 2.1 and later
131 | Request string
132 | ProtocolState string
133 | ProtocolName string
134 | HELOName string
135 | QueueId string
136 | Sender string
137 | Recipient string
138 | RecipientCount uint64
139 | ClientAddress net.IP
140 | ClientName string
141 | ReverseClientName string
142 | Instance string
143 |
144 | // Postfix version 2.2 and later
145 | SASLMethod string
146 | SASLUsername string
147 | SASLSender string
148 | Size uint64
149 | CCertSubject string
150 | CCertIssuer string
151 | CCertFingerprint string
152 |
153 | // Postfix version 2.3 and later
154 | EncryptionProtocol string
155 | EncryptionCipher string
156 | EncryptionKeysize uint64
157 | ETRNDomain string
158 |
159 | // Postfix version 2.5 and later
160 | Stress bool
161 |
162 | // Postfix version 2.9 and later
163 | CCertPubkeyFingerprint string
164 |
165 | // Postfix version 3.0 and later
166 | ClientPort uint64
167 |
168 | // Postfix version 3.1 and later
169 | PolicyContext string
170 |
171 | // Postfix version 3.2 and later
172 | ServerAddress net.IP
173 | ServerPort uint64
174 |
175 | // postfix-policy-server specific values
176 | PPSConnId string
177 | }
178 |
179 | // connection represents an incoming policy server connection
180 | type connection struct {
181 | conn net.Conn
182 | rs *bufio.Scanner
183 | h Handler
184 | err error
185 | cc bool
186 | }
187 |
188 | // Server defines a new policy server with corresponding settings
189 | type Server struct {
190 | lp string
191 | la string
192 | }
193 |
194 | // polSetFunc is a function alias that tries to fit a given value into a PolicySet
195 | type polSetFunc func(*PolicySet, string)
196 |
197 | // ServerOpt is an override function for the New() method
198 | type ServerOpt func(*Server)
199 |
200 | // Handler interface for handling incoming policy requests and returning the
201 | // corresponding action
202 | type Handler interface {
203 | Handle(*PolicySet) PostfixResp
204 | }
205 |
206 | // New returns a new server object
207 | func New(options ...ServerOpt) Server {
208 | s := Server{
209 | lp: DefaultPort,
210 | la: DefaultAddr,
211 | }
212 | for _, o := range options {
213 | if o == nil {
214 | continue
215 | }
216 | o(&s)
217 | }
218 |
219 | return s
220 | }
221 |
222 | // WithPort overrides the default listening port for the policy server
223 | func WithPort(p string) ServerOpt {
224 | return func(s *Server) {
225 | s.lp = p
226 | }
227 | }
228 |
229 | // WithAddr overrides the default listening address for the policy server
230 | func WithAddr(a string) ServerOpt {
231 | return func(s *Server) {
232 | s.la = a
233 | }
234 | }
235 |
236 | // SetPort will override the listening port on an already existing policy server
237 | func (s *Server) SetPort(p string) {
238 | s.lp = p
239 | }
240 |
241 | // SetAddr will override the listening address on an already existing policy server
242 | func (s *Server) SetAddr(a string) {
243 | s.la = a
244 | }
245 |
246 | // Run starts a server based on the Server object
247 | func (s *Server) Run(ctx context.Context, h Handler) error {
248 | sa := net.JoinHostPort(s.la, s.lp)
249 | l, err := net.Listen("tcp", sa)
250 | if err != nil {
251 | return err
252 | }
253 | return s.RunWithListener(ctx, h, l)
254 | }
255 |
256 | // RunWithListener starts a server based on the Server object with a given network listener
257 | func (s *Server) RunWithListener(ctx context.Context, h Handler, l net.Listener) error {
258 | el := log.New(os.Stderr, "[Server] ERROR: ", log.Lmsgprefix|log.LstdFlags|log.Lshortfile)
259 | noLog := false
260 | ok, nlv := ctx.Value(CtxNoLog).(bool)
261 | if ok {
262 | noLog = nlv
263 | }
264 |
265 | go func() {
266 | <-ctx.Done()
267 | if err := l.Close(); err != nil && !noLog {
268 | el.Printf("failed to close listener: %s", err)
269 | }
270 | }()
271 |
272 | // Accept new connections
273 | for {
274 | c, err := l.Accept()
275 | if err != nil {
276 | if !noLog {
277 | el.Printf("failed to accept new connection: %s", err)
278 | }
279 | break
280 | }
281 | conn := &connection{
282 | conn: c,
283 | rs: bufio.NewScanner(c),
284 | h: h,
285 | }
286 |
287 | connId := xid.New()
288 | conCtx := context.WithValue(ctx, ctxConnId, connId)
289 | ec := make(chan error, 1)
290 | go func() { ec <- connHandler(conCtx, conn) }()
291 | select {
292 | case <-conCtx.Done():
293 | <-ec
294 | return ctx.Err()
295 | case err := <-ec:
296 | return err
297 | }
298 | }
299 |
300 | return nil
301 | }
302 |
303 | // connHandler processes the incoming policy connection request and hands it to the
304 | // Handle function of the Handler interface
305 | func connHandler(ctx context.Context, c *connection) error {
306 | connId, ok := ctx.Value(ctxConnId).(xid.ID)
307 | if !ok {
308 | return fmt.Errorf("failed to retrieve connection id from context")
309 | }
310 |
311 | for !c.cc {
312 | ps := &PolicySet{PPSConnId: connId.String()}
313 | processMsg(c, ps)
314 | if ps.Request != "" {
315 | resp := c.h.Handle(ps)
316 | if err := c.conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil {
317 | c.err = fmt.Errorf("failed to set write deadline on connection: %s", err.Error())
318 | }
319 | sResp := fmt.Sprintf("action=%s\n\n", resp)
320 | if _, err := c.conn.Write([]byte(sResp)); err != nil {
321 | c.err = fmt.Errorf("failed to write response on connection: %s", err.Error())
322 | }
323 | }
324 | }
325 | return c.err
326 | }
327 |
328 | // processMsg processes the incoming policy message and updates the given PolicySet
329 | func processMsg(c *connection, ps *PolicySet) {
330 | for c.rs.Scan() {
331 | l := c.rs.Text()
332 | if l == "" {
333 | break
334 | }
335 | sl := strings.SplitN(l, "=", 2)
336 | if f, ok := polSetFuncs[sl[0]]; ok {
337 | f(ps, sl[1])
338 | }
339 | }
340 | if err := c.rs.Err(); err != nil {
341 | if _, ok := err.(*net.OpError); ok {
342 | return
343 | }
344 | c.err = err
345 | }
346 | }
347 |
348 | // TextResponseOpt allows you to use a PostfixResp with an optional text as response to the
349 | // Postfix server
350 | func TextResponseOpt(rt PostfixResp, t string) PostfixResp {
351 | r := PostfixResp(fmt.Sprintf("%s %s", rt, t))
352 | return r
353 | }
354 |
355 | // TextResponseNonOpt allows you to use a PostfixTextResp with a non-optional text as response to the
356 | // Postfix server
357 | func TextResponseNonOpt(rt PostfixTextResp, t string) PostfixResp {
358 | r := PostfixResp(fmt.Sprintf("%s %s", rt, t))
359 | return r
360 | }
361 |
--------------------------------------------------------------------------------
/pps_test.go:
--------------------------------------------------------------------------------
1 | package pps
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "net"
8 | "os"
9 | "testing"
10 | "time"
11 | )
12 |
13 | // Empty struct to test the Handler interface
14 | type Hi struct {
15 | r PostfixResp
16 | }
17 |
18 | // Handle is the function required by the Handler Interface
19 | func (h Hi) Handle(*PolicySet) PostfixResp {
20 | if h.r == "" {
21 | h.r = RespDunno
22 | }
23 | return h.r
24 | }
25 |
26 | const exampleReq = `request=smtpd_access_policy
27 | protocol_state=RCPT
28 | protocol_name=SMTP
29 | client_address=127.0.0.1
30 | client_name=localhost
31 | client_port=45140
32 | reverse_client_name=localhost
33 | server_address=127.0.0.1
34 | server_port=25
35 | helo_name=example.com
36 | sender=tester@example.com
37 | recipient=tester@localhost.tld
38 | recipient_count=0
39 | queue_id=
40 | instance=1234.5678910a.bcdef.0
41 | size=0
42 | etrn_domain=
43 | stress=
44 | sasl_method=
45 | sasl_username=
46 | sasl_sender=
47 | ccert_subject=
48 | ccert_issuer=
49 | ccert_fingerprint=
50 | ccert_pubkey_fingerprint=
51 | encryption_protocol=
52 | encryption_cipher=
53 | encryption_keysize=0
54 | policy_context=
55 |
56 | `
57 |
58 | // TestNew tests the New() method
59 | func TestNew(t *testing.T) {
60 | s := New()
61 | if s.lp != DefaultPort {
62 | t.Errorf("policy server creation failed: configured port mismatch => Expected: %s, got: %s",
63 | DefaultPort, s.lp)
64 | }
65 | if s.la != DefaultAddr {
66 | t.Errorf("policy server creation failed: configured listen address mismatch => Expected: %s, got: %s",
67 | DefaultAddr, s.la)
68 | }
69 | }
70 |
71 | // TestNewWithAddr tests the New() method with the WithAddr() option
72 | func TestNewWithAddr(t *testing.T) {
73 | a := "1.2.3.4"
74 | s := New(WithAddr(a))
75 | if s.la != a {
76 | t.Errorf("policy server creation failed: configured listen address mismatch => Expected: %s, got: %s",
77 | a, s.la)
78 | }
79 | }
80 |
81 | // TestNewWithPort tests the New() method with the WithPort() option
82 | func TestNewWithPort(t *testing.T) {
83 | p := "1234"
84 | s := New(WithPort(p))
85 | if s.lp != p {
86 | t.Errorf("policy server creation failed: configured listen address mismatch => Expected: %s, got: %s",
87 | p, s.lp)
88 | }
89 | }
90 |
91 | // TestSetAddr tests the SetAddr() option on an existing policy server
92 | func TestSetAddr(t *testing.T) {
93 | a := "1.2.3.4"
94 | s := New()
95 | s.SetAddr(a)
96 | if s.la != a {
97 | t.Errorf("policy server address setting failed => Expected: %s, got: %s",
98 | a, s.la)
99 | }
100 | }
101 |
102 | // TestSetPort tests the SetPort() option on an existing policy server
103 | func TestSetPort(t *testing.T) {
104 | p := "1234"
105 | s := New()
106 | s.SetPort(p)
107 | if s.lp != p {
108 | t.Errorf("policy server port setting failed => Expected: %s, got: %s",
109 | p, s.lp)
110 | }
111 | }
112 |
113 | // TestNewWithEmptyOpt tests the New() method with a nil-option
114 | func TestNewWithEmptyOpt(t *testing.T) {
115 | emptyOpt := func(p *string) ServerOpt { return nil }
116 | s := New(emptyOpt(nil))
117 | if s.lp != DefaultPort {
118 | t.Errorf("policy server creation failed: configured listen address mismatch => Expected: %s, got: %s",
119 | DefaultPort, s.lp)
120 | }
121 | }
122 |
123 | // TestRun starts a new server listening for connections
124 | func TestRun(t *testing.T) {
125 | testTable := []struct {
126 | testName string
127 | listenAddr string
128 | listenPort string
129 | shouldFail bool
130 | }{
131 | {`Successfully start with defaults`, DefaultAddr, DefaultPort, false},
132 | {`Fail to on invalid IP`, "256.256.256.256", DefaultPort, true},
133 | {`Fail to on invalid port`, DefaultAddr, "1", true},
134 | }
135 |
136 | for _, tc := range testTable {
137 | t.Run(tc.testName, func(t *testing.T) {
138 | s := New(WithAddr(tc.listenAddr), WithPort(tc.listenPort))
139 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200)
140 | defer cancel()
141 | vctx := context.WithValue(ctx, CtxNoLog, true)
142 |
143 | h := Hi{}
144 | err := s.Run(vctx, h)
145 | if err != nil && !tc.shouldFail {
146 | t.Errorf("could not run server: %s", err)
147 | }
148 | })
149 | }
150 | }
151 |
152 | // TestRunDial starts a new server listening for connections and tries to connect to it
153 | func TestRunDial(t *testing.T) {
154 | s := New(WithPort("44440"))
155 | sctx, scancel := context.WithCancel(context.Background())
156 | defer scancel()
157 | vsctx := context.WithValue(sctx, CtxNoLog, true)
158 |
159 | h := Hi{}
160 | go func() {
161 | if err := s.Run(vsctx, h); err != nil {
162 | t.Errorf("could not run server: %s", err)
163 | }
164 | }()
165 |
166 | // Wait a brief moment for the server to start
167 | time.Sleep(time.Millisecond * 200)
168 |
169 | d := net.Dialer{}
170 | cctx, ccancel := context.WithTimeout(context.Background(), time.Millisecond*500)
171 | defer ccancel()
172 | conn, err := d.DialContext(cctx, "tcp",
173 | fmt.Sprintf("%s:%s", s.la, s.lp))
174 | if err != nil {
175 | t.Errorf("failed to connect to running server: %s", err)
176 | return
177 | }
178 | if err := conn.Close(); err != nil {
179 | t.Errorf("failed to close client connection: %s", err)
180 | }
181 | }
182 |
183 | // TestRunDialWithRequest starts a new server listening for connections and tries to connect to it
184 | // and sends example data
185 | func TestRunDialWithRequest(t *testing.T) {
186 | s := New(WithPort("44441"))
187 | sctx, scancel := context.WithCancel(context.Background())
188 | defer scancel()
189 | vsctx := context.WithValue(sctx, CtxNoLog, true)
190 |
191 | h := Hi{}
192 | go func() {
193 | if err := s.Run(vsctx, h); err != nil {
194 | t.Errorf("could not run server: %s", err)
195 | }
196 | }()
197 |
198 | // Wait a brief moment for the server to start
199 | time.Sleep(time.Millisecond * 200)
200 |
201 | d := net.Dialer{}
202 | cctx, ccancel := context.WithTimeout(context.Background(), time.Millisecond*500)
203 | defer ccancel()
204 | conn, err := d.DialContext(cctx, "tcp",
205 | fmt.Sprintf("%s:%s", s.la, s.lp))
206 | if err != nil {
207 | t.Errorf("failed to connect to running server: %s", err)
208 | return
209 | }
210 | defer func() { _ = conn.Close() }()
211 | rb := bufio.NewReader(conn)
212 | _, err = conn.Write([]byte(exampleReq))
213 | if err != nil {
214 | t.Errorf("failed to send request to server: %s", err)
215 | }
216 | resp, err := rb.ReadString('\n')
217 | if err != nil {
218 | t.Errorf("failed to read response from server: %s", err)
219 | }
220 | exresp := fmt.Sprintf("action=%s\n", RespDunno)
221 | if resp != exresp {
222 | t.Errorf("unexpected server response => expected: %s, got: %s", exresp, resp)
223 | }
224 | }
225 |
226 | // TestRunDialReponses starts a new server listening for connections and tries to connect to it,
227 | // sends example data and tests all possible responses
228 | func TestRunDialResponses(t *testing.T) {
229 | testTable := []struct {
230 | testName string
231 | response PostfixResp
232 | port uint
233 | }{
234 | {`Test OK`, RespOk, 44442},
235 | {`Test REJECT`, RespReject, 44443},
236 | {`Test DEFER`, RespDefer, 44444},
237 | {`Test DEFER_IF_REJECT`, RespDeferIfReject, 44445},
238 | {`Test DEFER_IF_PERMIT`, RespDeferIfPermit, 44446},
239 | {`Test DISCARD`, RespDiscard, 44447},
240 | {`Test DUNNO`, RespDunno, 44448},
241 | {`Test HOLD`, RespHold, 44449},
242 | {`Test INFO`, RespInfo, 44450},
243 | {`Test WARN`, RespWarn, 44451},
244 | }
245 |
246 | for _, tc := range testTable {
247 | t.Run(tc.testName, func(t *testing.T) {
248 | s := New(WithPort(fmt.Sprintf("%d", tc.port)))
249 | sctx, scancel := context.WithCancel(context.Background())
250 | defer scancel()
251 | vsctx := context.WithValue(sctx, CtxNoLog, true)
252 | h := Hi{r: tc.response}
253 | go func() {
254 | if err := s.Run(vsctx, h); err != nil {
255 | t.Errorf("could not run server: %s", err)
256 | }
257 | }()
258 |
259 | // Wait a brief moment for the server to start
260 | time.Sleep(time.Millisecond * 200)
261 |
262 | d := net.Dialer{}
263 | cctx, ccancel := context.WithTimeout(context.Background(), time.Millisecond*500)
264 | defer ccancel()
265 | conn, err := d.DialContext(cctx, "tcp",
266 | fmt.Sprintf("%s:%s", s.la, s.lp))
267 | if err != nil {
268 | t.Errorf("failed to connect to running server: %s", err)
269 | return
270 | }
271 | defer func() { _ = conn.Close() }()
272 | rb := bufio.NewReader(conn)
273 | _, err = conn.Write([]byte(exampleReq))
274 | if err != nil {
275 | t.Errorf("failed to send request to server: %s", err)
276 | }
277 | resp, err := rb.ReadString('\n')
278 | if err != nil {
279 | t.Errorf("failed to read response from server: %s", err)
280 | }
281 | exresp := fmt.Sprintf("action=%s\n", tc.response)
282 | if resp != exresp {
283 | t.Errorf("unexpected server response => expected: %s, got: %s", exresp, resp)
284 | }
285 | })
286 | }
287 | }
288 |
289 | // TestRunDialWithRequestSocket starts a new server listening for connections on a UNIX socket,
290 | // tries to connect to it and sends example data
291 | func TestRunDialWithRequestSocket(t *testing.T) {
292 | s := New()
293 | sctx, scancel := context.WithCancel(context.Background())
294 | defer scancel()
295 | vsctx := context.WithValue(sctx, CtxNoLog, true)
296 | us := "/tmp/pps_test_server"
297 | l, err := net.Listen("unix", us)
298 | if err != nil {
299 | t.Errorf("failed to create new UNIX socket listener: %s", err)
300 | }
301 | defer func() {
302 | _ = os.Remove(us)
303 | }()
304 |
305 | h := Hi{}
306 | go func() {
307 | if err := s.RunWithListener(vsctx, h, l); err != nil {
308 | t.Errorf("could not run server: %s", err)
309 | }
310 | }()
311 |
312 | // Wait a brief moment for the server to start
313 | time.Sleep(time.Millisecond * 200)
314 |
315 | d := net.Dialer{}
316 | cctx, ccancel := context.WithTimeout(context.Background(), time.Millisecond*500)
317 | defer ccancel()
318 | conn, err := d.DialContext(cctx, "unix", us)
319 | if err != nil {
320 | t.Errorf("failed to connect to running server: %s", err)
321 | return
322 | }
323 | defer func() { _ = conn.Close() }()
324 | rb := bufio.NewReader(conn)
325 | _, err = conn.Write([]byte(exampleReq))
326 | if err != nil {
327 | t.Errorf("failed to send request to server: %s", err)
328 | }
329 | resp, err := rb.ReadString('\n')
330 | if err != nil {
331 | t.Errorf("failed to read response from server: %s", err)
332 | }
333 | exresp := fmt.Sprintf("action=%s\n", RespDunno)
334 | if resp != exresp {
335 | t.Errorf("unexpected server response => expected: %s, got: %s", exresp, resp)
336 | }
337 | }
338 |
339 | // TestRunDialOptTextResponse starts a new server listening for connections and tries to connect to it,
340 | // sends example data and tests the TextResponseOpt() method
341 | func TestRunDialTextResponseOpt(t *testing.T) {
342 | testTable := []struct {
343 | testName string
344 | postfixResp PostfixResp
345 | optText string
346 | port uint
347 | }{
348 | {`Test OK`, RespOk, "testtext", 44460},
349 | }
350 |
351 | for _, tc := range testTable {
352 | t.Run(tc.testName, func(t *testing.T) {
353 | s := New(WithPort(fmt.Sprintf("%d", tc.port)))
354 | sctx, scancel := context.WithCancel(context.Background())
355 | defer scancel()
356 | vsctx := context.WithValue(sctx, CtxNoLog, true)
357 |
358 | custResp := TextResponseOpt(tc.postfixResp, tc.optText)
359 | h := Hi{r: custResp}
360 | go func() {
361 | if err := s.Run(vsctx, h); err != nil {
362 | t.Errorf("could not run server: %s", err)
363 | }
364 | }()
365 |
366 | // Wait a brief moment for the server to start
367 | time.Sleep(time.Millisecond * 200)
368 |
369 | d := net.Dialer{}
370 | cctx, ccancel := context.WithTimeout(context.Background(), time.Millisecond*500)
371 | defer ccancel()
372 | conn, err := d.DialContext(cctx, "tcp",
373 | fmt.Sprintf("%s:%s", s.la, s.lp))
374 | if err != nil {
375 | t.Errorf("failed to connect to running server: %s", err)
376 | return
377 | }
378 | defer func() { _ = conn.Close() }()
379 | rb := bufio.NewReader(conn)
380 | _, err = conn.Write([]byte(exampleReq))
381 | if err != nil {
382 | t.Errorf("failed to send request to server: %s", err)
383 | }
384 | resp, err := rb.ReadString('\n')
385 | if err != nil {
386 | t.Errorf("failed to read response from server: %s", err)
387 | }
388 | exresp := fmt.Sprintf("action=%s %s\n", tc.postfixResp, tc.optText)
389 | if resp != exresp {
390 | t.Errorf("unexpected server response => expected: %s, got: %s", exresp, resp)
391 | }
392 | })
393 | }
394 | }
395 |
396 | // TestRunDialNonOptTextResponse starts a new server listening for connections and tries to connect to it,
397 | // sends example data and tests the TextResponseNonOpt() method
398 | func TestRunDialTextResponseNonOpt(t *testing.T) {
399 | testTable := []struct {
400 | testName string
401 | postfixResp PostfixTextResp
402 | optText string
403 | port uint
404 | }{
405 | {`Test PREPEND`, TextRespPrepend, "headername: headervalue", 44461},
406 | {`Test FILTER`, TextRespFilter, "transport:destination", 44462},
407 | {`Test REDIRECT`, TextRespRedirect, "user@domain", 44463},
408 | }
409 |
410 | for _, tc := range testTable {
411 | t.Run(tc.testName, func(t *testing.T) {
412 | s := New(WithPort(fmt.Sprintf("%d", tc.port)))
413 | sctx, scancel := context.WithCancel(context.Background())
414 | defer scancel()
415 | vsctx := context.WithValue(sctx, CtxNoLog, true)
416 |
417 | custResp := TextResponseNonOpt(tc.postfixResp, tc.optText)
418 | h := Hi{r: custResp}
419 | go func() {
420 | if err := s.Run(vsctx, h); err != nil {
421 | t.Errorf("could not run server: %s", err)
422 | }
423 | }()
424 |
425 | // Wait a brief moment for the server to start
426 | time.Sleep(time.Millisecond * 200)
427 |
428 | d := net.Dialer{}
429 | cctx, ccancel := context.WithTimeout(context.Background(), time.Millisecond*500)
430 | defer ccancel()
431 | conn, err := d.DialContext(cctx, "tcp",
432 | fmt.Sprintf("%s:%s", s.la, s.lp))
433 | if err != nil {
434 | t.Errorf("failed to connect to running server: %s", err)
435 | return
436 | }
437 | defer func() { _ = conn.Close() }()
438 | rb := bufio.NewReader(conn)
439 | _, err = conn.Write([]byte(exampleReq))
440 | if err != nil {
441 | t.Errorf("failed to send request to server: %s", err)
442 | }
443 | resp, err := rb.ReadString('\n')
444 | if err != nil {
445 | t.Errorf("failed to read response from server: %s", err)
446 | }
447 | exresp := fmt.Sprintf("action=%s %s\n", tc.postfixResp, tc.optText)
448 | if resp != exresp {
449 | t.Errorf("unexpected server response => expected: %s, got: %s", exresp, resp)
450 | }
451 | })
452 | }
453 | }
454 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.projectKey=postfix-policy-server
2 | sonar.go.coverage.reportPaths=cov.out
3 | sonar.externalIssuesReportPaths=report.json
4 |
--------------------------------------------------------------------------------