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

OIDC Auth for Funnel, k8s state support #1

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
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
81 changes: 81 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Go
on:
push:
branches:
- '**'
tags:
- 'v*.*.*'
jobs:

build:
name: Build
runs-on: ubuntu-latest
env:
CGO_ENABLED: 0
steps:

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: stable
id: go

- name: Check out code into the Go module directory
uses: actions/checkout@v4

- name: go mod download
run: |
go mod download

- name: Cross-build
run: |
GOOS=linux GOARCH=amd64 go build -o tsproxy-amd64 .
GOOS=linux GOARCH=arm64 go build -o tsproxy-arm64 .

- name: Test
run: |
go test ./...

- name: Lint
uses: golangci/golangci-lint-action@v4
with:
version: latest

- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: |
ghcr.io/sr/tsproxy
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha

- name: Set up QEMU
uses: docker/setup-qemu-action@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
config.hujson
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM debian:bookworm
ARG TARGETARCH

WORKDIR /app

RUN apt-get update && \
apt-get install -y ca-certificates

COPY tsproxy-$TARGETARCH /usr/bin/tsproxy

CMD ["/usr/bin/tsproxy"]
152 changes: 152 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"

"github.com/tailscale/hujson"
)

// configFile represents the on-disk configuration for this tsproxy instance.
type config struct {
// StateDir is where app state is stored, this should be persisted between
// rounds. Defaults to the user cache dir. If the Kubernetes config is set,
// it overrides this.
StateDir string `json:"stateDir"`
// Kubernetes configures the proxy to run in a kubernetes cluster. In this
// case the StateDir is ignored, and state managed in a secret.
Kubernetes kubernetesConfig `json:"kubernetes"`
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*kubernetesConfig? then can drop the enabled field.

// MetricsDiscovery port sets the port we should listen for internal items
// for, i.e metrics and discovery info
MetricsDiscoveryPort int `json:"port"`
// LogTailscale indicates that we should log tailscale output
LogTailscale bool `json:"logTailscale"`
Upstreams []upstream `json:"upstreams"`
}

// ConfigUpstream represents the configuration for a single upstream for this
// tsproxy instance.
type upstream struct {
// Name for this upstream. This is what it will be registered in tailscale
// as.
Name string `json:"name"`
// Backend is the URL to the backend that serves this upstream
Backend string `json:"backend"`
Prometheus bool `json:"prometheus"`
Funnel bool `json:"funnel"`

// FunnelPublicPatterns sets the list of patterns where public (aka
// unauthenticated) access are allowed. If not set, no open access is
// permitted - and auth must be configured. The patterns are in
// http.ServeMux format.
FunnelPublicPatterns []string `json:"funnelPublicPatterns"`

// OIDCIssuer sets the issuer to authenticate funnel access with. Any
// patterns not labeled as public will be handled by this.
OIDCIssuer string `json:"oidcIssuer"`
// OIDCClientID sets the OIDC client ID
OIDCClientID string `json:"oidcClientID"`
// OIDCClientSecret sets the OIDC client secret
OIDCClientSecret string `json:"oidcClientSecret"`
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

be cool to support a file too, play nice with systemd-creds. For flags I usually do this:

	if _, path, ok := strings.Cut(*cliSecret, "file://"); ok {
		b, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		s := strings.TrimSpace(string(b))
		cliSecret = &s
	}

but doesn't really work w/ a config file. Maybe oidcClientSecretFile?

Copy link
Owner

@sr sr Mar 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also wondering if this should be on the root config struct, or do you plan on using a different issuer for some upstream(s)?

}

type kubernetesConfig struct {
// Enabled enables the use of kubernetes for this proxy
Enabled bool `json:"enabled"`
// KubeconfigPath sets the path to the config to connect to the cluster. If
// not set, an in cluster config is used.
KubeconfigPath string `json:"kubeconfig"`
// Namespace to store the configmap in
Namespace string `json:"namespace"`
// Secret is the name of the secret, unique to this proxy instance
Secret string `json:"secret"`
}

func parseAndValidateConfig(cfg []byte) (config, error) {
b := []byte(os.Expand(string(cfg), getenvWithDefault))
var c config
b, err := hujson.Standardize(b)
if err != nil {
return c, fmt.Errorf("standardizing config: %w", err)
}
if err := json.Unmarshal(b, &c); err != nil {
return c, fmt.Errorf("unmarshaling config: %w", err)
}

// defaults
if c.MetricsDiscoveryPort == 0 {
c.MetricsDiscoveryPort = 32019
}

// validation
var verr error
if len(c.Upstreams) == 0 {
verr = errors.Join(verr, errors.New("at least one upstream must be provided"))
}
for _, u := range c.Upstreams {
if u.Name == "" {
verr = errors.Join(verr, errors.New("upstreams must have a name"))
}
if u.Backend == "" {
verr = errors.Join(verr, fmt.Errorf("upstream %s must have a backend", u.Name))
} else {
_, err := url.Parse(u.Backend)
if err != nil {
verr = errors.Join(verr, fmt.Errorf("upstream %s backend url %s failed parsing: %w", u.Name, u.Backend, err))
}
}

if u.OIDCIssuer != "" {
if u.OIDCClientID == "" {
verr = errors.Join(verr, fmt.Errorf("upstream %s oidcClientID required", u.Name))
}
if u.OIDCClientSecret == "" {
verr = errors.Join(verr, fmt.Errorf("upstream %s oidcClientSecret required", u.Name))
}
}
}
if c.Kubernetes.Enabled {
if c.Kubernetes.Namespace == "" {
verr = errors.Join(verr, fmt.Errorf("namespace must be set when kubernetes is enabled"))
}
if c.Kubernetes.Secret == "" {
verr = errors.Join(verr, fmt.Errorf("secret must be set when kubernetes is enabled"))
}
} else {
if c.StateDir == "" {
v, err := os.UserCacheDir()
if err != nil {
return c, fmt.Errorf("finding user cache dir: %w", err)
}
dir := filepath.Join(v, "tsproxy")
log.Printf("dir: %s", dir)
if err := os.MkdirAll(dir, 0o700); err != nil {
return c, fmt.Errorf("creating %s: %w", dir, err)
}
c.StateDir = dir
}
}

if verr != nil {
return c, fmt.Errorf("validating config failed: %w", err)
}

return c, nil
}

// getenvWithDefault maps FOO:-default to $FOO or default if $FOO is unset or
// null.
func getenvWithDefault(key string) string {
parts := strings.SplitN(key, ":-", 2)
val := os.Getenv(parts[0])
if val == "" && len(parts) == 2 {
val = parts[1]
}
return val
}
22 changes: 22 additions & 0 deletions config.hujson.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"logTailscale": true,
"kubernetes": {
"enabled": true,
"kubeconfig": "${HOME}/.kube/config",
"namespace": "default",
"secret": "tsproxy",
},
"upstreams": [
{
"name": "tsproxydev",
"backend": "http://localhost:8080",
"funnel": true,
"funnelPublicPatterns": [
"/testPublic",
],
"oidcIssuer": "https://oidc-issuer",
"oidcClientID": "tsproxydev",
"oidcClientSecret": "XXXX",
},
],
}
86 changes: 86 additions & 0 deletions cookie.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"

"github.com/lstoll/oidc/middleware"
)

const cookieName = "tsproxy-auth"

var _ middleware.SessionStore

type cookieAuthSession struct {
}

func (cookieAuthSession) Get(r *http.Request) (*middleware.SessionData, error) {
c, err := r.Cookie(cookieName)
if err != nil {
if err == http.ErrNoCookie {
return &middleware.SessionData{}, nil
}
return nil, fmt.Errorf("fetching cookie %s: %w", cookieName, err)
}

var sd middleware.SessionData

b, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil {
return nil, fmt.Errorf("base64 decode cookie: %w", err)
}
rdr, err := gzip.NewReader(bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("creating gzip reader: %w", err)
}
if err := json.NewDecoder(rdr).Decode(&sd); err != nil {
return nil, fmt.Errorf("decoding cookie: %w", err)
}

return &sd, nil
}

// Save should store the updated session. If the session data is nil, the
// session should be deleted.
func (cookieAuthSession) Save(w http.ResponseWriter, r *http.Request, sd *middleware.SessionData) error {
if sd == nil {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Secure: true,
HttpOnly: true,
Path: "/",
MaxAge: -1,
Value: "",
})
return nil
}

// unset access token as we don't use it, and refresh token because it's not
// safe to store unencrypted.
if sd.Token != nil {
sd.Token.AccessToken = ""
sd.Token.RefreshToken = ""
}

buf := bytes.Buffer{}
gzw := gzip.NewWriter(&buf)
if err := json.NewEncoder(gzw).Encode(sd); err != nil {
return fmt.Errorf("zip/enc cookie: %w", err)
}
if err := gzw.Close(); err != nil {
return fmt.Errorf("closing gzip writer: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Secure: true,
HttpOnly: true,
Path: "/",
Value: base64.RawURLEncoding.EncodeToString(buf.Bytes()),
})

return nil
}
Loading
Loading