-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: main
Are you sure you want to change the base?
Changes from all commits
fea3ef1
2cd2edf
0a06b11
a54d002
605d549
b86757c
2db2f47
c95b8e8
0e14a98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 }} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
config.hujson |
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"] |
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"` | ||
// 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"` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
but doesn't really work w/ a config file. Maybe oidcClientSecretFile? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
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", | ||
}, | ||
], | ||
} |
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 | ||
} |
There was a problem hiding this comment.
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.