auth+cli
This commit is contained in:
parent
314cbd37c7
commit
07522043b4
16 changed files with 870 additions and 38 deletions
|
|
@ -8,6 +8,8 @@ DEV_DEMO_USER=demo
|
||||||
DEV_DEMO_EMAIL=demo@example.local
|
DEV_DEMO_EMAIL=demo@example.local
|
||||||
AUTH_HEADER_USER=X-authentik-username
|
AUTH_HEADER_USER=X-authentik-username
|
||||||
AUTH_HEADER_EMAIL=X-authentik-email
|
AUTH_HEADER_EMAIL=X-authentik-email
|
||||||
|
OIDC_ISSUER_URL=
|
||||||
|
OIDC_AUDIENCE=
|
||||||
FORGEJO_BASE_URL=
|
FORGEJO_BASE_URL=
|
||||||
FORGEJO_TOKEN=
|
FORGEJO_TOKEN=
|
||||||
FORGEJO_GIT_USERNAME=oauth2
|
FORGEJO_GIT_USERNAME=oauth2
|
||||||
|
|
|
||||||
14
cmd/boxctl/main.go
Normal file
14
cmd/boxctl/main.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"box/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := app.RunCLI(os.Args[1:]); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
527
internal/app/cli.go
Normal file
527
internal/app/cli.go
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cliState struct {
|
||||||
|
APIURL string `json:"api_url"`
|
||||||
|
OIDCIssuerURL string `json:"oidc_issuer_url"`
|
||||||
|
OIDCClientID string `json:"oidc_client_id"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenExpiryUTC string `json:"token_expiry_utc"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
UnixUser string `json:"unix_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliClient struct {
|
||||||
|
baseURL string
|
||||||
|
state *cliState
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunCLI(args []string) error {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
if len(args) == 0 {
|
||||||
|
return cliUsage()
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "provision":
|
||||||
|
return runCLIProvision(args[1:])
|
||||||
|
case "login":
|
||||||
|
return runCLILogin(args[1:])
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
return cliUsage()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown command %q\n\n%s", args[0], cliUsageText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLIProvision(args []string) error {
|
||||||
|
state, _ := loadCLIState()
|
||||||
|
fs := flag.NewFlagSet("provision", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
var apiURL, workspace, unixUser, name, description, repoURL string
|
||||||
|
var repoPrivate bool
|
||||||
|
fs.StringVar(&apiURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL")
|
||||||
|
fs.StringVar(&workspace, "workspace", state.Workspace, "workspace name (required)")
|
||||||
|
fs.StringVar(&unixUser, "unix-user", state.UnixUser, "unix user for workspace (required)")
|
||||||
|
fs.StringVar(&name, "name", "", "project name (required)")
|
||||||
|
fs.StringVar(&description, "description", "", "project description")
|
||||||
|
fs.StringVar(&repoURL, "repo-url", "", "existing repository URL (optional)")
|
||||||
|
fs.BoolVar(&repoPrivate, "private", true, "create Forgejo repo as private when repo-url is empty")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
workspace = strings.TrimSpace(workspace)
|
||||||
|
unixUser = strings.TrimSpace(unixUser)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
repoURL = strings.TrimSpace(repoURL)
|
||||||
|
if apiURL == "" || workspace == "" || unixUser == "" || name == "" {
|
||||||
|
return errors.New("required flags: --api-url, --workspace, --unix-user, --name")
|
||||||
|
}
|
||||||
|
if repoURL != "" && !isValidRepoURL(repoURL) {
|
||||||
|
return errors.New("repo-url must be a valid repo URL (https://..., ssh://..., or git@...)")
|
||||||
|
}
|
||||||
|
|
||||||
|
state.APIURL = apiURL
|
||||||
|
state.Workspace = workspace
|
||||||
|
state.UnixUser = unixUser
|
||||||
|
c := cliClient{baseURL: apiURL, state: &state}
|
||||||
|
if err := c.ensureAccessToken(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveCLIState(state); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wsID, err := c.ensureWorkspace(workspace, unixUser)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slug := slugify(name)
|
||||||
|
existing, err := c.findProjectBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
p, err := c.reprovisionProject(existing.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printCLIProject("reprovisioned", p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, err := c.createProject(wsID, name, description, repoPrivate, repoURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printCLIProject("provisioned", p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLILogin(args []string) error {
|
||||||
|
state, _ := loadCLIState()
|
||||||
|
fs := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
fs.StringVar(&state.APIURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL")
|
||||||
|
fs.StringVar(&state.OIDCIssuerURL, "issuer", firstNonEmpty(state.OIDCIssuerURL, getenv("OIDC_ISSUER_URL", "")), "OIDC issuer URL (authentik)")
|
||||||
|
fs.StringVar(&state.OIDCClientID, "client-id", firstNonEmpty(state.OIDCClientID, getenv("OIDC_CLIENT_ID", "")), "OIDC client ID")
|
||||||
|
fs.StringVar(&state.Workspace, "workspace", state.Workspace, "default workspace name")
|
||||||
|
fs.StringVar(&state.UnixUser, "unix-user", state.UnixUser, "default workspace unix user")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.APIURL = strings.TrimRight(strings.TrimSpace(state.APIURL), "/")
|
||||||
|
state.OIDCIssuerURL = strings.TrimRight(strings.TrimSpace(state.OIDCIssuerURL), "/")
|
||||||
|
state.OIDCClientID = strings.TrimSpace(state.OIDCClientID)
|
||||||
|
state.Workspace = strings.TrimSpace(state.Workspace)
|
||||||
|
state.UnixUser = strings.TrimSpace(state.UnixUser)
|
||||||
|
if state.APIURL == "" || state.OIDCIssuerURL == "" || state.OIDCClientID == "" {
|
||||||
|
return errors.New("login requires --api-url, --issuer, and --client-id")
|
||||||
|
}
|
||||||
|
d, err := fetchOIDCDiscoveryForCLI(state.OIDCIssuerURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, expiry, refresh, err := runPKCEBrowserLogin(d, state.OIDCClientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.AccessToken = token
|
||||||
|
state.RefreshToken = refresh
|
||||||
|
state.TokenExpiryUTC = expiry.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
c := cliClient{baseURL: state.APIURL, state: &state}
|
||||||
|
me, err := c.me()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login token verification failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := saveCLIState(state); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("saved login config at %s\n", cliStatePath())
|
||||||
|
fmt.Printf("authenticated as username=%s email=%s\n", me.Username, me.Email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) ensureAccessToken() error {
|
||||||
|
if strings.TrimSpace(c.state.AccessToken) == "" {
|
||||||
|
return errors.New("not logged in: run `boxctl login`")
|
||||||
|
}
|
||||||
|
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(c.state.TokenExpiryUTC))
|
||||||
|
if err == nil && time.Now().UTC().Before(exp.Add(-1*time.Minute)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.state.RefreshToken) == "" {
|
||||||
|
return errors.New("access token expired and refresh token missing; run `boxctl login`")
|
||||||
|
}
|
||||||
|
d, err := fetchOIDCDiscoveryForCLI(c.state.OIDCIssuerURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "refresh_token")
|
||||||
|
form.Set("client_id", c.state.OIDCClientID)
|
||||||
|
form.Set("refresh_token", c.state.RefreshToken)
|
||||||
|
body, status, err := postForm(d.TokenURL, form)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status >= 300 {
|
||||||
|
return fmt.Errorf("token refresh failed status=%d body=%s", status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var tr tokenResponse
|
||||||
|
if err := json.Unmarshal(body, &tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tr.AccessToken) == "" {
|
||||||
|
return errors.New("token refresh returned empty access_token")
|
||||||
|
}
|
||||||
|
c.state.AccessToken = tr.AccessToken
|
||||||
|
if strings.TrimSpace(tr.RefreshToken) != "" {
|
||||||
|
c.state.RefreshToken = tr.RefreshToken
|
||||||
|
}
|
||||||
|
c.state.TokenExpiryUTC = time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPKCEBrowserLogin(d oidcDiscovery, clientID string) (accessToken string, expiry time.Time, refreshToken string, err error) {
|
||||||
|
verifier, err := randomURLSafe(64)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
state, err := randomURLSafe(24)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
challenge := pkceS256(verifier)
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
redirectURI := "http://" + ln.Addr().String() + "/callback"
|
||||||
|
|
||||||
|
codeCh := make(chan string, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("state") != state {
|
||||||
|
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||||||
|
errCh <- errors.New("state mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "missing code", http.StatusBadRequest)
|
||||||
|
errCh <- errors.New("missing authorization code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("Login successful. You can close this window."))
|
||||||
|
codeCh <- code
|
||||||
|
})
|
||||||
|
server := &http.Server{Handler: mux}
|
||||||
|
go func() { _ = server.Serve(ln) }()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
authURL, err := url.Parse(d.AuthURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
q := authURL.Query()
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("client_id", clientID)
|
||||||
|
q.Set("redirect_uri", redirectURI)
|
||||||
|
q.Set("scope", "openid profile email offline_access")
|
||||||
|
q.Set("code_challenge", challenge)
|
||||||
|
q.Set("code_challenge_method", "S256")
|
||||||
|
q.Set("state", state)
|
||||||
|
authURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
fmt.Printf("Open this URL to log in:\n%s\n", authURL.String())
|
||||||
|
_ = openBrowser(authURL.String())
|
||||||
|
|
||||||
|
var code string
|
||||||
|
select {
|
||||||
|
case code = <-codeCh:
|
||||||
|
case err = <-errCh:
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
case <-time.After(180 * time.Second):
|
||||||
|
return "", time.Time{}, "", errors.New("login timeout waiting for callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "authorization_code")
|
||||||
|
form.Set("client_id", clientID)
|
||||||
|
form.Set("code", code)
|
||||||
|
form.Set("redirect_uri", redirectURI)
|
||||||
|
form.Set("code_verifier", verifier)
|
||||||
|
body, status, err := postForm(d.TokenURL, form)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
if status >= 300 {
|
||||||
|
return "", time.Time{}, "", fmt.Errorf("token exchange failed status=%d body=%s", status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var tr tokenResponse
|
||||||
|
if err := json.Unmarshal(body, &tr); err != nil {
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tr.AccessToken) == "" {
|
||||||
|
return "", time.Time{}, "", errors.New("token exchange returned empty access_token")
|
||||||
|
}
|
||||||
|
return tr.AccessToken, time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second), tr.RefreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(u string) error {
|
||||||
|
for _, cmd := range [][]string{
|
||||||
|
{"xdg-open", u},
|
||||||
|
{"open", u},
|
||||||
|
} {
|
||||||
|
if err := exec.Command(cmd[0], cmd[1:]...).Start(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomURLSafe(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pkceS256(verifier string) string {
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchOIDCDiscoveryForCLI(issuer string) (oidcDiscovery, error) {
|
||||||
|
resp, err := http.Get(strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration")
|
||||||
|
if err != nil {
|
||||||
|
return oidcDiscovery{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return oidcDiscovery{}, fmt.Errorf("oidc discovery failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var d oidcDiscovery
|
||||||
|
if err := json.Unmarshal(body, &d); err != nil {
|
||||||
|
return oidcDiscovery{}, err
|
||||||
|
}
|
||||||
|
if d.AuthURL == "" || d.TokenURL == "" {
|
||||||
|
return oidcDiscovery{}, errors.New("oidc discovery missing authorization_endpoint/token_endpoint")
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) ensureWorkspace(name, unixUser string) (int64, error) {
|
||||||
|
var workspaces []Workspace
|
||||||
|
if err := c.getJSON("/api/workspaces", &workspaces); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, w := range workspaces {
|
||||||
|
if w.Name == name {
|
||||||
|
if w.UnixUser != unixUser {
|
||||||
|
return 0, fmt.Errorf("workspace %q already exists with unix_user=%q (requested=%q)", name, w.UnixUser, unixUser)
|
||||||
|
}
|
||||||
|
return w.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var created Workspace
|
||||||
|
if err := c.postJSON("/api/workspaces", map[string]any{"name": name, "unix_user": unixUser}, &created); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return created.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) findProjectBySlug(slug string) (*Project, error) {
|
||||||
|
var projects []Project
|
||||||
|
if err := c.getJSON("/api/projects", &projects); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range projects {
|
||||||
|
if projects[i].Slug == slug {
|
||||||
|
return &projects[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) reprovisionProject(projectID int64) (Project, error) {
|
||||||
|
var p Project
|
||||||
|
err := c.postJSON(fmt.Sprintf("/api/projects/%d/reprovision", projectID), map[string]any{}, &p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) createProject(workspaceID int64, name, description string, repoPrivate bool, repoURL string) (Project, error) {
|
||||||
|
var p Project
|
||||||
|
err := c.postJSON("/api/projects", map[string]any{
|
||||||
|
"workspace_id": workspaceID,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"repo_private": repoPrivate,
|
||||||
|
"repo_url": repoURL,
|
||||||
|
}, &p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) me() (User, error) {
|
||||||
|
var u User
|
||||||
|
err := c.getJSON("/api/me", &u)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) getJSON(path string, out any) error {
|
||||||
|
if err := c.ensureAccessToken(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.state.AccessToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cliClient) postJSON(path string, payload any, out any) error {
|
||||||
|
if err := c.ensureAccessToken(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.state.AccessToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCLIProject(action string, p Project) {
|
||||||
|
fmt.Printf("project %s id=%d name=%s slug=%s\n", action, p.ID, p.Name, p.Slug)
|
||||||
|
fmt.Printf("workspace=%s unix_user=%s\n", p.Workspace, p.UnixUser)
|
||||||
|
fmt.Printf("route_host=%s\n", p.RouteHost)
|
||||||
|
if p.AppURL != "" {
|
||||||
|
fmt.Printf("app_url=%s\n", p.AppURL)
|
||||||
|
}
|
||||||
|
if p.RepoURL != "" {
|
||||||
|
fmt.Printf("repo_url=%s\n", p.RepoURL)
|
||||||
|
}
|
||||||
|
if p.WebhookURL != "" {
|
||||||
|
fmt.Printf("webhook_url=%s\n", p.WebhookURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliStatePath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return ".boxctl.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "boxctl", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCLIState() (cliState, error) {
|
||||||
|
path := cliStatePath()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cliState{}, nil
|
||||||
|
}
|
||||||
|
return cliState{}, err
|
||||||
|
}
|
||||||
|
var s cliState
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
return cliState{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCLIState(s cliState) error {
|
||||||
|
path := cliStatePath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(s, "", " ")
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliUsage() error {
|
||||||
|
fmt.Fprint(os.Stderr, cliUsageText())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliUsageText() string {
|
||||||
|
return `Usage:
|
||||||
|
go run . # server
|
||||||
|
go run ./cmd/boxctl login --api-url https://box.example.com --issuer https://auth.example.com/application/o/box --client-id box-cli [--workspace <name>] [--unix-user <unix_user>]
|
||||||
|
go run ./cmd/boxctl provision --name <project_name> [--repo-url <url>] [--description <text>] [--private=true|false]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
login OAuth browser login (PKCE) and save tokens/defaults in ~/.config/boxctl/config.json
|
||||||
|
provision Upsert project via API using bearer token
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,8 @@ func loadConfig() (Config, error) {
|
||||||
DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"),
|
DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"),
|
||||||
AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"),
|
AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"),
|
||||||
AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"),
|
AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"),
|
||||||
|
OIDCIssuerURL: strings.TrimRight(getenv("OIDC_ISSUER_URL", ""), "/"),
|
||||||
|
OIDCAudience: strings.TrimSpace(getenv("OIDC_AUDIENCE", "")),
|
||||||
ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"),
|
ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"),
|
||||||
ForgejoToken: getenv("FORGEJO_TOKEN", ""),
|
ForgejoToken: getenv("FORGEJO_TOKEN", ""),
|
||||||
ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"),
|
ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ func (a *App) migrate(ctx context.Context) error {
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
UNIQUE(project_id, key)
|
UNIQUE(project_id, key)
|
||||||
);`,
|
);`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_webhook_tokens (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);`,
|
||||||
}
|
}
|
||||||
for _, q := range ddl {
|
for _, q := range ddl {
|
||||||
if _, err := a.db.ExecContext(ctx, q); err != nil {
|
if _, err := a.db.ExecContext(ctx, q); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ END $$;`, sqlQuoteLiteral(p.DBUser), sqlQuoteIdent(p.DBUser), sqlQuoteLiteral(p.
|
||||||
return fmt.Errorf("check database existence failed: %w", err)
|
return fmt.Errorf("check database existence failed: %w", err)
|
||||||
}
|
}
|
||||||
if !dbExists {
|
if !dbExists {
|
||||||
if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, sqlQuoteIdent(p.DBName), sqlQuoteIdent(p.DBUser))); err != nil {
|
if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s OWNER %s TEMPLATE template0`, sqlQuoteIdent(p.DBName), sqlQuoteIdent(p.DBUser))); err != nil {
|
||||||
return fmt.Errorf("create database failed: %w", err)
|
return fmt.Errorf("create database failed: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ func (a *App) handleDeployWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userToken := strings.TrimSpace(r.URL.Query().Get("ut"))
|
||||||
|
ok, err := a.validateUserWebhookToken(r.Context(), p.UserID, userToken)
|
||||||
|
if err != nil || !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
ref, err := parseWebhookRef(r)
|
ref, err := parseWebhookRef(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid webhook payload", http.StatusBadRequest)
|
http.Error(w, "invalid webhook payload", http.StatusBadRequest)
|
||||||
|
|
@ -98,7 +104,11 @@ func (a *App) getProjectForWebhook(ctx context.Context, projectID int64, token s
|
||||||
}
|
}
|
||||||
return Project{}, err
|
return Project{}, err
|
||||||
}
|
}
|
||||||
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
|
tok, err := a.ensureUserWebhookToken(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return Project{}, err
|
||||||
|
}
|
||||||
|
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token)
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,6 +28,28 @@ func (a *App) withAuth(next func(http.ResponseWriter, *http.Request, User)) http
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) withAPIUser(next func(http.ResponseWriter, *http.Request, User)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.cfg.DevMode {
|
||||||
|
u := User{Username: a.cfg.DemoUser, Email: a.cfg.DemoEmail}
|
||||||
|
next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)), u)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authz := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
if !strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
||||||
|
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(authz[len("Bearer "):])
|
||||||
|
u, err := a.validateBearerToken(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid bearer token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)), u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loggingMiddleware(next http.Handler) http.Handler {
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
|
||||||
118
internal/app/oidc_auth.go
Normal file
118
internal/app/oidc_auth.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type oidcDiscovery struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
UserInfo string `json:"userinfo_endpoint"`
|
||||||
|
TokenURL string `json:"token_endpoint"`
|
||||||
|
AuthURL string `json:"authorization_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) fetchOIDCDiscovery() (oidcDiscovery, error) {
|
||||||
|
if strings.TrimSpace(a.cfg.OIDCIssuerURL) == "" {
|
||||||
|
return oidcDiscovery{}, errors.New("OIDC_ISSUER_URL is required")
|
||||||
|
}
|
||||||
|
u := a.cfg.OIDCIssuerURL + "/.well-known/openid-configuration"
|
||||||
|
resp, err := http.Get(u)
|
||||||
|
if err != nil {
|
||||||
|
return oidcDiscovery{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return oidcDiscovery{}, fmt.Errorf("oidc discovery failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var d oidcDiscovery
|
||||||
|
if err := json.Unmarshal(body, &d); err != nil {
|
||||||
|
return oidcDiscovery{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.UserInfo) == "" {
|
||||||
|
return oidcDiscovery{}, errors.New("oidc discovery missing userinfo_endpoint")
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) validateBearerToken(token string) (User, error) {
|
||||||
|
d, err := a.fetchOIDCDiscovery()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, d.UserInfo, nil)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return User{}, fmt.Errorf("oidc userinfo failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var claims map[string]any
|
||||||
|
if err := json.Unmarshal(body, &claims); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if aud := strings.TrimSpace(a.cfg.OIDCAudience); aud != "" {
|
||||||
|
if !claimContainsAudience(claims["aud"], aud) {
|
||||||
|
return User{}, errors.New("token audience does not match configured OIDC_AUDIENCE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username := firstStringClaim(claims, "preferred_username", "username", "sub")
|
||||||
|
if strings.TrimSpace(username) == "" {
|
||||||
|
return User{}, errors.New("oidc token missing preferred_username/username/sub")
|
||||||
|
}
|
||||||
|
email := firstStringClaim(claims, "email")
|
||||||
|
return User{Username: username, Email: email}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimContainsAudience(v any, expected string) bool {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(t), expected)
|
||||||
|
case []any:
|
||||||
|
for _, item := range t {
|
||||||
|
if s, ok := item.(string); ok && strings.EqualFold(strings.TrimSpace(s), expected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstStringClaim(claims map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := claims[k]; ok {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForm(urlStr string, values url.Values) ([]byte, int, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(values.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,11 @@ func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error
|
||||||
t := deployedAt.Time.UTC()
|
t := deployedAt.Time.UTC()
|
||||||
p.LastDeployedAt = &t
|
p.LastDeployedAt = &t
|
||||||
}
|
}
|
||||||
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
|
tok, err := a.ensureUserWebhookToken(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token)
|
||||||
p.AppURL = a.appURL(p.RouteHost)
|
p.AppURL = a.appURL(p.RouteHost)
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +84,11 @@ func (a *App) createProject(ctx context.Context, user User, workspaceID int64, n
|
||||||
|
|
||||||
p.Workspace = workspace.Name
|
p.Workspace = workspace.Name
|
||||||
p.UnixUser = workspace.UnixUser
|
p.UnixUser = workspace.UnixUser
|
||||||
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
|
tok, err := a.ensureUserWebhookToken(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return Project{}, err
|
||||||
|
}
|
||||||
|
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token)
|
||||||
p.AppURL = a.appURL(p.RouteHost)
|
p.AppURL = a.appURL(p.RouteHost)
|
||||||
return p, a.provisionProject(ctx, &p)
|
return p, a.provisionProject(ctx, &p)
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +176,11 @@ func (a *App) getProject(ctx context.Context, userID string, projectID int64) (P
|
||||||
t := deployedAt.Time.UTC()
|
t := deployedAt.Time.UTC()
|
||||||
p.LastDeployedAt = &t
|
p.LastDeployedAt = &t
|
||||||
}
|
}
|
||||||
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
|
tok, err := a.ensureUserWebhookToken(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return Project{}, err
|
||||||
|
}
|
||||||
|
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token)
|
||||||
p.AppURL = a.appURL(p.RouteHost)
|
p.AppURL = a.appURL(p.RouteHost)
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,19 @@ import (
|
||||||
func (a *App) routes() http.Handler {
|
func (a *App) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/", http.FileServer(http.Dir("./web")))
|
mux.Handle("/", http.FileServer(http.Dir("./web")))
|
||||||
mux.HandleFunc("/api/me", a.withAuth(a.handleMe))
|
mux.HandleFunc("/web-api/me", a.withAuth(a.handleMe))
|
||||||
mux.HandleFunc("/api/workspaces", a.withAuth(a.handleWorkspaces))
|
mux.HandleFunc("/web-api/workspaces", a.withAuth(a.handleWorkspaces))
|
||||||
mux.HandleFunc("/api/projects", a.withAuth(a.handleProjects))
|
mux.HandleFunc("/web-api/projects", a.withAuth(a.handleProjects))
|
||||||
mux.HandleFunc("/api/projects/", a.withAuth(a.handleProjectSubroutes))
|
mux.HandleFunc("/web-api/projects/", a.withAuth(a.handleProjectSubroutes))
|
||||||
mux.HandleFunc("/api/caddy/config", a.withAuth(a.handleCaddyConfig))
|
mux.HandleFunc("/web-api/caddy/config", a.withAuth(a.handleCaddyConfig))
|
||||||
|
mux.HandleFunc("/web-api/tokens/webhook", a.withAuth(a.handleWebhookToken))
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/me", a.withAPIUser(a.handleMe))
|
||||||
|
mux.HandleFunc("/api/workspaces", a.withAPIUser(a.handleWorkspaces))
|
||||||
|
mux.HandleFunc("/api/projects", a.withAPIUser(a.handleProjects))
|
||||||
|
mux.HandleFunc("/api/projects/", a.withAPIUser(a.handleProjectSubroutes))
|
||||||
|
mux.HandleFunc("/api/caddy/config", a.withAPIUser(a.handleCaddyConfig))
|
||||||
|
mux.HandleFunc("/api/tokens/webhook", a.withAPIUser(a.handleWebhookToken))
|
||||||
mux.HandleFunc("/api/webhooks/deploy/", a.handleDeployWebhook)
|
mux.HandleFunc("/api/webhooks/deploy/", a.handleDeployWebhook)
|
||||||
return loggingMiddleware(mux)
|
return loggingMiddleware(mux)
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +119,9 @@ func (a *App) handleProjects(w http.ResponseWriter, r *http.Request, user User)
|
||||||
|
|
||||||
func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, user User) {
|
func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, user User) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/projects/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/projects/")
|
||||||
|
if path == r.URL.Path {
|
||||||
|
path = strings.TrimPrefix(r.URL.Path, "/web-api/projects/")
|
||||||
|
}
|
||||||
parts := strings.Split(path, "/")
|
parts := strings.Split(path, "/")
|
||||||
if len(parts) < 1 || parts[0] == "" {
|
if len(parts) < 1 || parts[0] == "" {
|
||||||
http.Error(w, "bad path", http.StatusBadRequest)
|
http.Error(w, "bad path", http.StatusBadRequest)
|
||||||
|
|
@ -297,3 +308,24 @@ func (a *App) handleCaddyConfig(w http.ResponseWriter, r *http.Request, _ User)
|
||||||
"content": mustJSONPretty(cfg),
|
"content": mustJSONPretty(cfg),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) handleWebhookToken(w http.ResponseWriter, r *http.Request, user User) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
tok, err := a.ensureUserWebhookToken(r.Context(), user.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tok)
|
||||||
|
case http.MethodPost:
|
||||||
|
tok, err := a.rotateUserWebhookToken(r.Context(), user.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tok)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,32 +14,43 @@ import (
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run() error {
|
func initApp() (*App, func(), error) {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("config error: %w", err)
|
return nil, nil, fmt.Errorf("config error: %w", err)
|
||||||
}
|
}
|
||||||
db, err := sql.Open("pgx", cfg.DatabaseURL)
|
db, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("database open error: %w", err)
|
return nil, nil, fmt.Errorf("database open error: %w", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
cleanup := func() { _ = db.Close() }
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return fmt.Errorf("database ping error: %w", err)
|
cleanup()
|
||||||
|
return nil, nil, fmt.Errorf("database ping error: %w", err)
|
||||||
}
|
}
|
||||||
app := &App{cfg: cfg, db: db}
|
a := &App{cfg: cfg, db: db}
|
||||||
if err := app.migrate(context.Background()); err != nil {
|
if err := a.migrate(context.Background()); err != nil {
|
||||||
return fmt.Errorf("migration error: %w", err)
|
cleanup()
|
||||||
|
return nil, nil, fmt.Errorf("migration error: %w", err)
|
||||||
}
|
}
|
||||||
|
return a, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run() error {
|
||||||
|
app, cleanup, err := initApp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
handler := app.routes()
|
handler := app.routes()
|
||||||
servers, cleanup, err := startServers(cfg, handler)
|
servers, sockCleanup, err := startServers(app.cfg, handler)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("server start error: %w", err)
|
return fmt.Errorf("server start error: %w", err)
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer sockCleanup()
|
||||||
|
|
||||||
log.Printf("server started: tcp=%q unix=%q dev_mode=%t", cfg.ListenAddr, cfg.UnixSocketPath, cfg.DevMode)
|
log.Printf("server started: tcp=%q unix=%q dev_mode=%t", app.cfg.ListenAddr, app.cfg.UnixSocketPath, app.cfg.DevMode)
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
<-sigCh
|
<-sigCh
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ type Config struct {
|
||||||
DemoEmail string
|
DemoEmail string
|
||||||
AuthHeaderUser string
|
AuthHeaderUser string
|
||||||
AuthHeaderEmail string
|
AuthHeaderEmail string
|
||||||
|
OIDCIssuerURL string
|
||||||
|
OIDCAudience string
|
||||||
ForgejoBaseURL string
|
ForgejoBaseURL string
|
||||||
ForgejoToken string
|
ForgejoToken string
|
||||||
ForgejoGitUsername string
|
ForgejoGitUsername string
|
||||||
|
|
@ -83,3 +85,9 @@ type Workspace struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnixUser string `json:"unix_user"`
|
UnixUser string `json:"unix_user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserWebhookToken struct {
|
||||||
|
UserID string `json:"-"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
|
||||||
65
internal/app/user_tokens_service.go
Normal file
65
internal/app/user_tokens_service.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) getUserWebhookToken(ctx context.Context, userID string) (UserWebhookToken, error) {
|
||||||
|
var t UserWebhookToken
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT user_id, token, created_at FROM user_webhook_tokens WHERE user_id=$1`, userID).
|
||||||
|
Scan(&t.UserID, &t.Token, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return UserWebhookToken{}, errors.New("token not found")
|
||||||
|
}
|
||||||
|
return UserWebhookToken{}, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureUserWebhookToken(ctx context.Context, userID string) (UserWebhookToken, error) {
|
||||||
|
t, err := a.getUserWebhookToken(ctx, userID)
|
||||||
|
if err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
token, err := newDeployToken()
|
||||||
|
if err != nil {
|
||||||
|
return UserWebhookToken{}, err
|
||||||
|
}
|
||||||
|
var out UserWebhookToken
|
||||||
|
err = a.db.QueryRowContext(ctx, `INSERT INTO user_webhook_tokens (user_id, token) VALUES ($1,$2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET token=user_webhook_tokens.token
|
||||||
|
RETURNING user_id, token, created_at`, userID, token).
|
||||||
|
Scan(&out.UserID, &out.Token, &out.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return UserWebhookToken{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) rotateUserWebhookToken(ctx context.Context, userID string) (UserWebhookToken, error) {
|
||||||
|
token, err := newDeployToken()
|
||||||
|
if err != nil {
|
||||||
|
return UserWebhookToken{}, err
|
||||||
|
}
|
||||||
|
var out UserWebhookToken
|
||||||
|
err = a.db.QueryRowContext(ctx, `INSERT INTO user_webhook_tokens (user_id, token, created_at) VALUES ($1,$2,NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET token=EXCLUDED.token, created_at=NOW()
|
||||||
|
RETURNING user_id, token, created_at`, userID, token).
|
||||||
|
Scan(&out.UserID, &out.Token, &out.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return UserWebhookToken{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) validateUserWebhookToken(ctx context.Context, userID, token string) (bool, error) {
|
||||||
|
if userID == "" || token == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM user_webhook_tokens WHERE user_id=$1 AND token=$2)`, userID, token).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -47,11 +48,11 @@ func mustJSONPretty(data any) string {
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) deployWebhookURL(projectID int64, token string) string {
|
func (a *App) deployWebhookURL(projectID int64, token, userToken string) string {
|
||||||
if token == "" {
|
if token == "" || userToken == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
path := fmt.Sprintf("/api/webhooks/deploy/%d/%s", projectID, token)
|
path := fmt.Sprintf("/api/webhooks/deploy/%d/%s?ut=%s", projectID, token, url.QueryEscape(userToken))
|
||||||
if a.cfg.WebhookBaseURL != "" {
|
if a.cfg.WebhookBaseURL != "" {
|
||||||
return a.cfg.WebhookBaseURL + path
|
return a.cfg.WebhookBaseURL + path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
web/app.js
30
web/app.js
|
|
@ -1,5 +1,7 @@
|
||||||
|
const API_BASE = '/web-api';
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
|
|
@ -10,7 +12,7 @@ async function api(path, options = {}) {
|
||||||
const state = { projects: [], workspaces: [] };
|
const state = { projects: [], workspaces: [] };
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const me = await api('/api/me');
|
const me = await api('/me');
|
||||||
document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`;
|
document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`;
|
||||||
|
|
||||||
document.getElementById('workspaceForm').addEventListener('submit', createWorkspace);
|
document.getElementById('workspaceForm').addEventListener('submit', createWorkspace);
|
||||||
|
|
@ -22,7 +24,7 @@ async function init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWorkspaces() {
|
async function refreshWorkspaces() {
|
||||||
state.workspaces = await api('/api/workspaces');
|
state.workspaces = await api('/workspaces');
|
||||||
const select = document.getElementById('workspaceSelect');
|
const select = document.getElementById('workspaceSelect');
|
||||||
if (state.workspaces.length === 0) {
|
if (state.workspaces.length === 0) {
|
||||||
select.innerHTML = '<option value="">Create a workspace first</option>';
|
select.innerHTML = '<option value="">Create a workspace first</option>';
|
||||||
|
|
@ -34,7 +36,7 @@ async function refreshWorkspaces() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshProjects() {
|
async function refreshProjects() {
|
||||||
state.projects = await api('/api/projects');
|
state.projects = await api('/projects');
|
||||||
renderProjects();
|
renderProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +82,7 @@ function renderProjects() {
|
||||||
div.querySelector('[data-add]').addEventListener('click', async () => {
|
div.querySelector('[data-add]').addEventListener('click', async () => {
|
||||||
const key = div.querySelector('[data-k]').value.trim();
|
const key = div.querySelector('[data-k]').value.trim();
|
||||||
const value = div.querySelector('[data-v]').value;
|
const value = div.querySelector('[data-v]').value;
|
||||||
await api(`/api/projects/${p.id}/env`, {
|
await api(`/projects/${p.id}/env`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ key, value })
|
body: JSON.stringify({ key, value })
|
||||||
});
|
});
|
||||||
|
|
@ -88,7 +90,7 @@ function renderProjects() {
|
||||||
});
|
});
|
||||||
|
|
||||||
div.querySelector('[data-regen]').addEventListener('click', async () => {
|
div.querySelector('[data-regen]').addEventListener('click', async () => {
|
||||||
await api(`/api/projects/${p.id}/service`, { method: 'POST' });
|
await api(`/projects/${p.id}/service`, { method: 'POST' });
|
||||||
alert('Service regenerated and systemctl --user reload attempted.');
|
alert('Service regenerated and systemctl --user reload attempted.');
|
||||||
});
|
});
|
||||||
div.querySelector('[data-status]').addEventListener('click', async () => {
|
div.querySelector('[data-status]').addEventListener('click', async () => {
|
||||||
|
|
@ -98,7 +100,7 @@ function renderProjects() {
|
||||||
await loadLogs(div, p.id);
|
await loadLogs(div, p.id);
|
||||||
});
|
});
|
||||||
div.querySelector('[data-reprovision]').addEventListener('click', async () => {
|
div.querySelector('[data-reprovision]').addEventListener('click', async () => {
|
||||||
await api(`/api/projects/${p.id}/reprovision`, { method: 'POST' });
|
await api(`/projects/${p.id}/reprovision`, { method: 'POST' });
|
||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,7 +111,7 @@ function renderProjects() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEnvList(projectNode, projectID) {
|
async function loadEnvList(projectNode, projectID) {
|
||||||
const envs = await api(`/api/projects/${projectID}/env`);
|
const envs = await api(`/projects/${projectID}/env`);
|
||||||
const envRoot = projectNode.querySelector('[data-env]');
|
const envRoot = projectNode.querySelector('[data-env]');
|
||||||
if (envs.length === 0) {
|
if (envs.length === 0) {
|
||||||
envRoot.innerHTML = 'No env vars';
|
envRoot.innerHTML = 'No env vars';
|
||||||
|
|
@ -122,7 +124,7 @@ async function loadEnvList(projectNode, projectID) {
|
||||||
envRoot.querySelectorAll('[data-del]').forEach((btn) => {
|
envRoot.querySelectorAll('[data-del]').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const key = btn.getAttribute('data-del');
|
const key = btn.getAttribute('data-del');
|
||||||
await api(`/api/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
await api(`/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||||
await loadEnvList(projectNode, projectID);
|
await loadEnvList(projectNode, projectID);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +134,7 @@ async function loadStatus(projectNode, projectID) {
|
||||||
const out = projectNode.querySelector('[data-status-out]');
|
const out = projectNode.querySelector('[data-status-out]');
|
||||||
out.textContent = 'status: loading...';
|
out.textContent = 'status: loading...';
|
||||||
try {
|
try {
|
||||||
const s = await api(`/api/projects/${projectID}/status`);
|
const s = await api(`/projects/${projectID}/status`);
|
||||||
out.textContent = `status: ${s.active_state}/${s.sub_state} pid=${s.main_pid} result=${s.result || 'n/a'} exit=${s.exec_status}`;
|
out.textContent = `status: ${s.active_state}/${s.sub_state} pid=${s.main_pid} result=${s.result || 'n/a'} exit=${s.exec_status}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
out.textContent = `status error: ${err.message}`;
|
out.textContent = `status error: ${err.message}`;
|
||||||
|
|
@ -143,7 +145,7 @@ async function loadLogs(projectNode, projectID) {
|
||||||
const out = projectNode.querySelector('[data-logs-out]');
|
const out = projectNode.querySelector('[data-logs-out]');
|
||||||
out.textContent = 'loading logs...';
|
out.textContent = 'loading logs...';
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/projects/${projectID}/logs?lines=100`);
|
const res = await api(`/projects/${projectID}/logs?lines=100`);
|
||||||
out.textContent = res.logs || '(no logs)';
|
out.textContent = res.logs || '(no logs)';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
out.textContent = `logs error: ${err.message}`;
|
out.textContent = `logs error: ${err.message}`;
|
||||||
|
|
@ -163,7 +165,7 @@ async function createProject(e) {
|
||||||
const msg = document.getElementById('createMsg');
|
const msg = document.getElementById('createMsg');
|
||||||
msg.textContent = 'Creating project...';
|
msg.textContent = 'Creating project...';
|
||||||
try {
|
try {
|
||||||
await api('/api/projects', {
|
await api('/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
@ -186,7 +188,7 @@ async function createWorkspace(e) {
|
||||||
const msg = document.getElementById('workspaceMsg');
|
const msg = document.getElementById('workspaceMsg');
|
||||||
msg.textContent = 'Creating workspace...';
|
msg.textContent = 'Creating workspace...';
|
||||||
try {
|
try {
|
||||||
await api('/api/workspaces', {
|
await api('/workspaces', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
@ -202,7 +204,7 @@ async function loadCaddyConfig() {
|
||||||
const out = document.getElementById('caddyConfigOut');
|
const out = document.getElementById('caddyConfigOut');
|
||||||
out.textContent = 'Loading Caddy config...';
|
out.textContent = 'Loading Caddy config...';
|
||||||
try {
|
try {
|
||||||
const cfg = await api('/api/caddy/config');
|
const cfg = await api('/caddy/config');
|
||||||
out.textContent = cfg.content || JSON.stringify(cfg, null, 2);
|
out.textContent = cfg.content || JSON.stringify(cfg, null, 2);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
out.textContent = `Error: ${err.message}`;
|
out.textContent = `Error: ${err.message}`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue