-
Notifications
You must be signed in to change notification settings - Fork 1
OIDC Auth for Funnel, k8s state support #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
base: main
Are you sure you want to change the base?
Changes from all commits
fea3ef1
2cd2edf
0a06b11
a54d002
605d549
b86757c
2db2f47
c95b8e8
0e14a98
d0934f2
fe82028
ac3c92a
4c5ce13
be810d7
6e55665
c148545
578f95e
2b37e04
89b79f7
7101485
78e05be
3674b79
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: Check out code into the Go module directory | ||
uses: actions/checkout@v4 | ||
|
||
- name: Set up Go | ||
uses: actions/setup-go@v5 | ||
with: | ||
go-version: stable | ||
id: go | ||
|
||
- 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@v8 | ||
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?
Owner
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", | ||
}, | ||
], | ||
} |
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.