Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add built-in PKI #20

Merged
merged 9 commits into from
Nov 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
uses: actions/setup-go@v4
with:
go-version: '>=1.21.3'
- name: Generate files
run: go generate ./...
- name: Build
run: go build ./...
- name: Run go vet
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jobs:
uses: actions/setup-go@v4
with:
go-version: '>=1.21.3'
- name: Generate files
run: go generate ./...
- name: Build
run: go build ./...
- name: Run go vet
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ RUN apk add ca-certificates
ADD . /app/go/src/tlsproxy
WORKDIR /app/go/src/tlsproxy
RUN go mod download
RUN go generate ./...
RUN source version.sh && go install -ldflags="-s -w -X main.Version=${VERSION:-dev}" .

FROM scratch
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Overview of features:
* [x] Terminate _TCP_ connections, and forward the TLS connection to any TLS server (passthrough). The proxy doesn't see the plaintext.
* [x] Terminate HTTPS connections, and forward the requests to HTTP or HTTPS servers (http/1 only, not recommended with c2fmzq-server).
* [x] TLS client authentication & authorization (when the proxy terminates the TLS connections).
* [x] Built-in Certificate Authority for managing client and backend server TLS certificates.
* [x] User authentication with OpenID Connect, SAML, and/or passkeys (for HTTP and HTTPS connections). Optionally issue JSON Web Tokens (JWT) to authenticated users to use with the backend services and/or run a local OpenID Connect server for backend services.
* [x] Access control by IP address.
* [x] Routing based on Server Name Indication (SNI), with optional default route when SNI isn't used.
Expand Down Expand Up @@ -117,12 +118,13 @@ backends:
- restricted.example.com
mode: https
clientAuth:
rootCAs: |
rootCAs:
- |
-----BEGIN CERTIFICATE-----
.....
-----END CERTIFICATE-----
acl:
- CN=admin-user
- SUBJECT:CN=admin-user
addresses:
- 192.168.4.100:443
forwardServerName: restricted-internal.example.com
Expand Down
2 changes: 2 additions & 0 deletions build-release-binaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

mkdir -p bin

go generate ./...

export CGO_ENABLED=0
export GOARM=7
flag="-ldflags=-extldflags=-static -s -w -X main.Version=${GITHUB_REF_NAME:-dev}"
Expand Down
9 changes: 6 additions & 3 deletions examples/example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,20 @@ backends:
- serverNames:
- secure.example.com
clientAuth:
rootCAs: *myCA
rootCAs:
- *myCA
mode: tls
addresses:
- 192.168.2.200:443
forwardServerName: secure-internal.example.com
forwardRootCAs: *myCA
forwardRootCAs:
- *myCA

- serverNames:
- ssh.example.com
clientAuth:
rootCAs: *myCA
rootCAs:
- *myCA
addresses:
- 192.168.8.20:22

Expand Down
54 changes: 54 additions & 0 deletions examples/pki/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# PKI, built-in Certificate Authority

TLSPROXY has built-in support for managing X.509 Certificates.

```yaml
pki:
- name: "EXAMPLE CA"
# Optional: Publish the CA's certificate(s).
issuingCertificateUrls:
- https://pki.example.com/ca.pem
# Optional: Publish the CA's Revocation List.
crlDistributionPoints:
- https://pki.example.com/crl.pem
# Optional: Enable OCSP (Online Certificate Status Protocol).
ocspServers:
- https://pki.example.com/ocsp
# Users can manage their own certificates with this endpoint.
endpoint: https://pki-internal.example.com/certs
# Optional: Admins can revoke anybody's certificates.
admins:
- bob@example.com

backends:
# Optional: Use a server name to publich the CA's certificate and Revocation
# List.
- serverNames:
- pki.example.com
mode: local

# This server name is used to manage certificates.
- serverNames:
- pki-internal.example.com
mode: local
allowIPs:
- 192.168.0.0/24
sso:
provider: sso-provider # definition omitted for this example
forceReAuth: 1h
# The ACL controls who has access to issue and revoke certificates for
# themselves.
acl:
- alice@example.com
- bob@example.com

# Then use EXAMPLE CA to authenticate and authorize TLS clients.
- serverNames:
- secure.example.com
clientAuth:
- rootCAs:
- "EXAMPLE CA"
acl:
- EMAIL:alice@example.com
- EMAIL:bob@example.com
```
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.21

require (
github.com/beevik/etree v1.2.0
github.com/c2FmZQ/storage v0.1.0
github.com/c2FmZQ/storage v0.1.1
github.com/fxamacker/cbor/v2 v2.5.0
github.com/go-test/deep v1.1.0
github.com/golang-jwt/jwt/v5 v5.0.0
Expand All @@ -13,6 +13,7 @@ require (
golang.org/x/sys v0.13.0
golang.org/x/time v0.3.0
gopkg.in/yaml.v3 v3.0.1
software.sslmate.com/src/go-pkcs12 v0.3.0
)

require (
Expand Down
14 changes: 6 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw=
github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc=
github.com/c2FmZQ/storage v0.1.0 h1:ZhAV43sS5bWNfeEZZElnNUDyg56wJh3u2J9UFNRDbI4=
github.com/c2FmZQ/storage v0.1.0/go.mod h1:rKT3TTRmsAFp5IcI3hV3KNCQYqfDy9oOJ7uZxkyjSS8=
github.com/c2FmZQ/storage v0.1.1 h1:gYXqsX2Kloarl4UZnF0P2H1EN5mNfJObtdogDtznDnQ=
github.com/c2FmZQ/storage v0.1.1/go.mod h1:Nc3Sw1ghQRXrWNPGU81XdhHmcrmF9Pk2a4YDE/7sZrQ=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down Expand Up @@ -38,16 +38,10 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
Expand All @@ -63,3 +57,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.2.1 h1:tbT1jjaeFOF230tzOIRJ6U5S1jNqpsSyNjzDd58H3J8=
software.sslmate.com/src/go-pkcs12 v0.2.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
software.sslmate.com/src/go-pkcs12 v0.3.0 h1:ZYaL72OA2n9UgvesM62z1xmb4PYjgzswQ7xkuC08FEI=
software.sslmate.com/src/go-pkcs12 v0.3.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
19 changes: 15 additions & 4 deletions proxy/backend-sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,22 @@ func (be *Backend) enforceSSOPolicy(w http.ResponseWriter, req *http.Request) bo
return true
}
claims := claimsFromCtx(req.Context())
if claims == nil {
var iat time.Time
if claims != nil {
if p, _ := claims.GetIssuedAt(); p != nil {
iat = p.Time
}
}
hh := sha256.Sum256([]byte(req.Host))
// Request authentication when:
// * the user isn't logged in, or
// * the backend has ForceReAuth set, and the last authentication
// either on a different host, or too long ago.
if claims == nil || (be.SSO.ForceReAuth != 0 && (claims["hhash"] != hex.EncodeToString(hh[:]) || time.Since(iat) > be.SSO.ForceReAuth)) {
u := req.URL
u.Scheme = "https"
u.Host = req.Host
log.Printf("REQ %s ➔ %s %s ➔ status:%d (SSO)", formatReqDesc(req), req.Method, req.RequestURI, http.StatusFound)
log.Printf("REQ %s ➔ %s %s ➔ status:%d (SSO) (%q)", formatReqDesc(req), req.Method, req.RequestURI, http.StatusFound, userAgent(req))
be.SSO.p.RequestLogin(w, req, u.String())
return false
}
Expand All @@ -277,7 +288,7 @@ func (be *Backend) enforceSSOPolicy(w http.ResponseWriter, req *http.Request) bo
_, userDomain, _ := strings.Cut(userID, "@")
if be.SSO.ACL != nil && !slices.Contains(*be.SSO.ACL, userID) && !slices.Contains(*be.SSO.ACL, "@"+userDomain) {
be.recordEvent(fmt.Sprintf("deny %s to %s", userID, host))
log.Printf("REQ %s ➔ %s %s ➔ status:%d (SSO)", formatReqDesc(req), req.Method, req.RequestURI, http.StatusForbidden)
log.Printf("REQ %s ➔ %s %s ➔ status:%d (SSO) (%q)", formatReqDesc(req), req.Method, req.RequestURI, http.StatusForbidden, userAgent(req))
be.servePermissionDenied(w, req)
return false
}
Expand All @@ -304,6 +315,6 @@ func (be *Backend) makeTokenForURL(req *http.Request) (string, string, error) {
token, err := be.tm.CreateToken(jwt.MapClaims{
"url": u.String(),
"exp": time.Now().Add(time.Hour).Unix(),
}, "ES256")
}, "EdDSA")
return token, u.String(), err
}
68 changes: 49 additions & 19 deletions proxy/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ package proxy
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -115,9 +117,17 @@ func (be *Backend) dial(proto string) (net.Conn, error) {
InsecureSkipVerify: be.InsecureSkipVerify,
ServerName: be.ForwardServerName,
NextProtos: protos,
}
if be.forwardRootCAs != nil {
tc.RootCAs = be.forwardRootCAs
RootCAs: be.forwardRootCAs,
VerifyConnection: func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return errors.New("no certificate")
}
cert := cs.PeerCertificates[0]
if m, ok := be.pkiMap[hex.EncodeToString(cert.AuthorityKeyId)]; ok && m.IsRevoked(cert.SerialNumber) {
return errRevoked
}
return nil
},
}
c, err = tls.DialWithDialer(dialer, "tcp", addr, tc)
} else {
Expand All @@ -135,14 +145,29 @@ func (be *Backend) dial(proto string) (net.Conn, error) {
}
}

func (be *Backend) authorize(subject string) error {
func (be *Backend) authorize(cert *x509.Certificate) error {
if be.ClientAuth == nil || be.ClientAuth.ACL == nil {
return nil
}
if subject == "" || !slices.Contains(*be.ClientAuth.ACL, subject) {
return errAccessDenied
if subject := cert.Subject.String(); subject != "" && (slices.Contains(*be.ClientAuth.ACL, subject) || slices.Contains(*be.ClientAuth.ACL, "SUBJECT:"+subject)) {
return nil
}
return nil
for _, v := range cert.DNSNames {
if slices.Contains(*be.ClientAuth.ACL, "DNS:"+v) {
return nil
}
}
for _, v := range cert.EmailAddresses {
if slices.Contains(*be.ClientAuth.ACL, "EMAIL:"+v) {
return nil
}
}
for _, v := range cert.URIs {
if slices.Contains(*be.ClientAuth.ACL, "URI:"+v.String()) {
return nil
}
}
return errAccessDenied
}

func (be *Backend) checkIP(addr net.Addr) error {
Expand Down Expand Up @@ -200,21 +225,26 @@ func (be *Backend) bridgeConns(client, server net.Conn) error {

func (be *Backend) localHandlersAndAuthz(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
h, exists := be.localHandlers[req.URL.Path]
if exists && h.ssoBypass {
h.handler.ServeHTTP(w, req)
hi := slices.IndexFunc(be.localHandlers, func(h localHandler) bool {
return req.URL.Path == h.path || (h.matchPrefix && strings.HasPrefix(req.URL.Path, h.path+"/"))
})
if hi >= 0 && be.localHandlers[hi].ssoBypass {
be.localHandlers[hi].handler.ServeHTTP(w, req)
return
}
if !be.enforceSSOPolicy(w, req) {
return
}
if exists && !h.ssoBypass {
h.handler.ServeHTTP(w, req)
if hi >= 0 && !be.localHandlers[hi].ssoBypass {
be.localHandlers[hi].handler.ServeHTTP(w, req)
return
}
if !exists {
if _, ok := be.localHandlers[req.URL.Path+"/"]; ok {
http.Redirect(w, req, req.URL.Path+"/", http.StatusMovedPermanently)
if hi < 0 {
pathSlash := req.URL.Path + "/"
if hi := slices.IndexFunc(be.localHandlers, func(h localHandler) bool {
return pathSlash == h.path
}); hi >= 0 {
http.Redirect(w, req, pathSlash, http.StatusMovedPermanently)
return
}
}
Expand All @@ -226,15 +256,15 @@ func (be *Backend) localHandlersAndAuthz(next http.Handler) http.Handler {
})
}

func (be *Backend) consoleHandler() http.Handler {
return be.userAuthentication(logHandler(be.localHandlersAndAuthz(nil)))
func (be *Backend) localHandler() http.Handler {
return be.userAuthentication(be.localHandlersAndAuthz(nil))
}

func (be *Backend) reverseProxy() http.Handler {
var rp http.Handler
if len(be.Addresses) == 0 {
rp = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Printf("PRX %s ➔ %s %s ➔ status:%d", formatReqDesc(req), req.Method, req.URL, http.StatusNotFound)
log.Printf("PRX %s ➔ %s %s ➔ status:%d (%q)", formatReqDesc(req), req.Method, req.URL, http.StatusNotFound, userAgent(req))
http.NotFound(w, req)
})
} else {
Expand Down Expand Up @@ -293,7 +323,7 @@ func (be *Backend) reverseProxyModifyResponse(resp *http.Response) error {
if resp.ContentLength != -1 {
cl = fmt.Sprintf(" content-length:%d", resp.ContentLength)
}
log.Printf("PRX %s ➔ %s %s ➔ status:%d%s", formatReqDesc(req), req.Method, req.URL, resp.StatusCode, cl)
log.Printf("PRX %s ➔ %s %s ➔ status:%d%s (%q)", formatReqDesc(req), req.Method, req.URL, resp.StatusCode, cl, userAgent(req))

if resp.StatusCode != http.StatusMisdirectedRequest && resp.Header.Get(hstsHeader) == "" {
resp.Header.Set(hstsHeader, hstsValue)
Expand Down
Loading