├── .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 | 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 | [![Go Reference](https://pkg.go.dev/badge/github.com/wneessen/postfix-policy-server.svg)](https://pkg.go.dev/github.com/wneessen/postfix-policy-server) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/wneessen/postfix-policy-server)](https://goreportcard.com/report/github.com/wneessen/postfix-policy-server) 4 | [![Build Status](https://api.cirrus-ci.com/github/wneessen/postfix-policy-server.svg)](https://cirrus-ci.com/github/wneessen/postfix-policy-server) 5 | [![pps docs](https://img.shields.io/badge/%F0%9F%92%A1%20pps-docs-00ACD7.svg?style=flat)](https://pps-docs.pebcak.de/) 6 | buy ma a coffee 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 | --------------------------------------------------------------------------------