Skip to content
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
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,27 +69,36 @@ The control plane serves Connect over HTTPS on 443. Protocol-transparent mTLS ga
The CLI never receives Docker access. Swarm remains private to the server and provides
detached job identity, logs, status, cancellation, and resource-aware queuing.

## Enroll a developer laptop
## Sign in from a developer laptop

An administrator creates the user, grants project membership, and generates a ten-minute
single-use code:
An administrator creates the Autback user, grants project membership, and binds the
user's immutable GitHub account ID. The GitHub login is resolved once by the server and
is retained only as display metadata:

```console
autback admin user create --name coworker
autback admin member add --project example --user usr...
autback admin enrollment create --user usr... --device coworker-laptop --expires 10m
autback admin identity github --user usr... --login coworker-github-login
# Later, revoke the binding and every active human credential:
autback admin identity revoke --user usr...
```

The coworker runs `autback login` and enters that code at the hidden prompt. autback exchanges
it once and stores the resulting named device token in macOS Keychain, Linux Secret
Service, or Windows Credential Manager through the operating-system keyring. `autback logout`
removes the local entry; `autback token revoke <id>` independently revokes one laptop.
The coworker runs `autback login`. The CLI opens Autback's GitHub sign-in and approval page,
then receives one independent device token and stores it in macOS Keychain, Linux Secret
Service, or Windows Credential Manager through the operating-system keyring. GitHub proves
human identity; it never becomes the durable CLI credential. `autback logout` removes the
local entry and `autback token revoke <id>` revokes one laptop server-side. A single-use
enrollment code remains available only as the documented recovery path through
`autback login --recovery-code`.

## Read-only governance console

Run `autback console` to open the live service console. The CLI creates an ephemeral,
random loopback session and injects the device credential into proxied `/app` requests;
the browser never receives the Keychain token and cannot reach the Connect control API.
Open the configured public service URL or run `autback console` to open the live service
console. The public console authenticates through GitHub and keeps an independent,
revocable Autback browser session in a secure HttpOnly cookie. The CLI command remains a
private loopback alternative that injects the device credential into proxied `/app`
requests; the browser never receives the Keychain token in either model and cannot reach
the Connect control API.
The console is deliberately read-only: all execution, cancellation, enrollment, trust,
and image commands remain in the CLI and audit log.

Expand Down
26 changes: 26 additions & 0 deletions api/rtest/v1/control.proto
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ service ControlService {
rpc GetServiceInfo(GetServiceInfoRequest) returns (GetServiceInfoResponse);

rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc BindGitHubIdentity(BindGitHubIdentityRequest) returns (BindGitHubIdentityResponse);
rpc RevokeGitHubIdentity(RevokeGitHubIdentityRequest) returns (RevokeGitHubIdentityResponse);
rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse);
rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
rpc AddProjectMember(AddProjectMemberRequest) returns (AddProjectMemberResponse);
Expand Down Expand Up @@ -437,3 +439,27 @@ message CancelBuildRequest {
message CancelBuildResponse {
Build build = 1;
}

message ExternalIdentity {
string provider = 1;
string subject = 2;
string login = 3;
string user_id = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp last_authenticated_at = 6;
}

message BindGitHubIdentityRequest {
string user_id = 1;
string login = 2;
}

message BindGitHubIdentityResponse {
ExternalIdentity identity = 1;
}

message RevokeGitHubIdentityRequest {
string user_id = 1;
}

message RevokeGitHubIdentityResponse {}
106 changes: 93 additions & 13 deletions cmd/autback-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync/atomic"
Expand All @@ -37,9 +38,11 @@ import (
controlsqlite "github.com/flidai/autback/internal/control/sqlite"
"github.com/flidai/autback/internal/control/swarmscheduler"
"github.com/flidai/autback/internal/hostmetrics"
"github.com/flidai/autback/internal/humanauth"
operationcleanup "github.com/flidai/autback/internal/operation/cleanup"
jobsecrets "github.com/flidai/autback/internal/secrets"
"github.com/flidai/autback/internal/version"
"golang.org/x/crypto/acme/autocert"
)

func main() {
Expand Down Expand Up @@ -183,13 +186,40 @@ func run(ctx context.Context) error {
JobPreparationLeaseTimeout: durationEnv("AUTBACK_JOB_PREPARATION_LEASE_TIMEOUT", 2*time.Minute),
})
var verifier controlapi.OIDCVerifier
if audience := os.Getenv("AUTBACK_GITHUB_OIDC_AUDIENCE"); audience != "" {
verifier, err = githuboidc.New(processCtx, env("AUTBACK_GITHUB_OIDC_ISSUER", githuboidc.Issuer), audience)
audienceConfig := os.Getenv("AUTBACK_GITHUB_OIDC_AUDIENCES")
if audienceConfig == "" {
audienceConfig = os.Getenv("AUTBACK_GITHUB_OIDC_AUDIENCE")
}
if audiences := splitValues(audienceConfig); len(audiences) > 0 {
verifier, err = githuboidc.NewWithAudiences(processCtx, env("AUTBACK_GITHUB_OIDC_ISSUER", githuboidc.Issuer), audiences)
if err != nil {
return err
}
}
draining := &atomic.Bool{}
var humanAuthHandler http.Handler
var githubHumanAuth *humanauth.GitHub
githubClientID, githubClientSecret := os.Getenv("AUTBACK_GITHUB_CLIENT_ID"), os.Getenv("AUTBACK_GITHUB_CLIENT_SECRET")
if githubClientID != "" || githubClientSecret != "" {
publicURL := strings.TrimRight(os.Getenv("AUTBACK_PUBLIC_URL"), "/")
if githubClientID == "" || githubClientSecret == "" || publicURL == "" {
return errors.New("AUTBACK_GITHUB_CLIENT_ID, AUTBACK_GITHUB_CLIENT_SECRET, and AUTBACK_PUBLIC_URL must be configured together")
}
githubHumanAuth, err = humanauth.NewGitHub(humanauth.GitHubConfig{
ClientID: githubClientID, ClientSecret: githubClientSecret, CallbackURL: publicURL + "/auth/github/callback",
})
if err != nil {
return err
}
humanAuthHandler, err = humanauth.New(humanauth.Config{
Store: store, GitHub: githubHumanAuth, PublicURL: publicURL,
SessionTTL: durationEnv("AUTBACK_BROWSER_SESSION_TTL", 7*24*time.Hour),
LoginTTL: durationEnv("AUTBACK_LOGIN_TTL", 10*time.Minute),
})
if err != nil {
return err
}
}
controlHandler, err := controlapi.New(controlapi.Config{
Store: store, Scheduler: scheduler, Dispatcher: dispatch, Authority: authority, OIDCVerifier: verifier,
CASEndpoint: env("AUTBACK_CAS_ENDPOINT", endpoint(serverName, casListen)), CASInstance: casInstance,
Expand All @@ -200,6 +230,7 @@ func run(ctx context.Context) error {
RequiredBuildClientCapability: version.CapabilityBuildLeaseHeartbeat,
RequiredJobClientCapability: version.CapabilityDurableJobPrepare,
Ready: func() bool { return !draining.Load() },
GitHubDirectory: githubHumanAuth,
})
if err != nil {
return err
Expand All @@ -210,11 +241,15 @@ func run(ctx context.Context) error {
if err != nil {
return err
}
consoleHandler, err := console.New(console.Config{Source: consoleSource})
consoleConfig := console.Config{Source: consoleSource}
if humanAuthHandler != nil {
consoleConfig.LoginURL = "/auth/login"
}
consoleHandler, err := console.New(consoleConfig)
if err != nil {
return err
}
handler := serviceHandler(controlHandler, consoleHandler)
handler := serviceHandler(controlHandler, consoleHandler, humanAuthHandler)
active := func(kind pki.Operation, id string) bool {
return store.OperationActive(context.Background(), string(kind), id)
}
Expand All @@ -228,9 +263,15 @@ func run(ctx context.Context) error {
return fmt.Errorf("listen for BuildKit proxy: %w", err)
}
defer buildKitListener.Close()
controlTLSConfig, controlCertificate, controlKey, err := controlTLS(
filepath.Join(dataDir, "acme"), pkiDir, names, os.Getenv("AUTBACK_ACME_DOMAIN"), os.Getenv("AUTBACK_ACME_EMAIL"),
)
if err != nil {
return err
}
server := &http.Server{
Addr: env("AUTBACK_LISTEN", ":8443"), Handler: handler, ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13},
IdleTimeout: 2 * time.Minute, TLSConfig: controlTLSConfig,
}
controlListener, err := net.Listen("tcp", server.Addr)
if err != nil {
Expand Down Expand Up @@ -283,7 +324,7 @@ func run(ctx context.Context) error {
group.Add(appserver.Component{
Name: "control HTTP server",
Run: func(ctx context.Context) error {
err := server.ServeTLS(controlListener, filepath.Join(pkiDir, "server.pem"), filepath.Join(pkiDir, "server-key.pem"))
err := server.ServeTLS(controlListener, controlCertificate, controlKey)
if errors.Is(err, http.ErrServerClosed) || ctx.Err() != nil {
return nil
}
Expand All @@ -305,6 +346,37 @@ func run(ctx context.Context) error {
return nil
}

func controlTLS(acmeDir, pkiDir string, names []string, acmeDomain, email string) (*tls.Config, string, string, error) {
if acmeDomain == "" {
return &tls.Config{MinVersion: tls.VersionTLS13}, filepath.Join(pkiDir, "server.pem"), filepath.Join(pkiDir, "server-key.pem"), nil
}
if !slices.Contains(names, acmeDomain) {
return nil, "", "", errors.New("AUTBACK_ACME_DOMAIN must be included in AUTBACK_SERVER_NAMES")
}
if err := os.MkdirAll(acmeDir, 0o700); err != nil {
return nil, "", "", err
}
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(acmeDir), Email: email,
HostPolicy: autocert.HostWhitelist(acmeDomain),
}
config := manager.TLSConfig()
config.MinVersion = tls.VersionTLS13
privateCertificate, err := tls.LoadX509KeyPair(filepath.Join(pkiDir, "server.pem"), filepath.Join(pkiDir, "server-key.pem"))
if err != nil {
return nil, "", "", fmt.Errorf("load private control certificate: %w", err)
}
config.Certificates = []tls.Certificate{privateCertificate}
acmeCertificate := config.GetCertificate
config.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if strings.EqualFold(hello.ServerName, acmeDomain) {
return acmeCertificate(hello)
}
return &config.Certificates[0], nil
}
return config, "", "", nil
}

func runCapacityController(ctx context.Context, controller *capacity.Controller, statusPath string, pressureInterval, maintenanceInterval time.Duration) {
if pressureInterval <= 0 {
pressureInterval = 5 * time.Second
Expand Down Expand Up @@ -334,10 +406,13 @@ func runCapacityController(ctx context.Context, controller *capacity.Controller,
}
}

func serviceHandler(controlHandler, consoleHandler http.Handler) http.Handler {
func serviceHandler(controlHandler, consoleHandler, authHandler http.Handler) http.Handler {
mux := http.NewServeMux()
mux.Handle("/app", consoleHandler)
mux.Handle("/app/", consoleHandler)
if authHandler != nil {
mux.Handle("/auth/", authHandler)
}
mux.Handle("/", controlHandler)
return mux
}
Expand Down Expand Up @@ -562,18 +637,23 @@ func closeListener(listener net.Listener) error {
}

func splitNames(value string) ([]string, error) {
var names []string
for _, item := range strings.Split(value, ",") {
if name := strings.TrimSpace(item); name != "" {
names = append(names, name)
}
}
names := splitValues(value)
if len(names) == 0 {
return nil, errors.New("AUTBACK_SERVER_NAMES must contain at least one DNS name or IP address")
}
return names, nil
}

func splitValues(value string) []string {
var values []string
for _, item := range strings.Split(value, ",") {
if item = strings.TrimSpace(item); item != "" {
values = append(values, item)
}
}
return values
}

func endpoint(serverName, listen string) string {
_, port, err := net.SplitHostPort(listen)
if err != nil || port == "" {
Expand Down
49 changes: 47 additions & 2 deletions cmd/autback-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,44 @@ package main

import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"

"github.com/flidai/autback/internal/control/pki"
)

func TestControlTLSUsesACMEForThePublicNameAndPrivatePKIForLegacyClients(t *testing.T) {
pkiDir := filepath.Join(t.TempDir(), "pki")
if _, err := pki.Ensure(pkiDir, []string{"console.autback.dev", "62.238.54.70"}); err != nil {
t.Fatal(err)
}
config, certificate, key, err := controlTLS(t.TempDir(), pkiDir, []string{"console.autback.dev", "62.238.54.70"}, "console.autback.dev", "ops@example.com")
if err != nil {
t.Fatal(err)
}
if certificate != "" || key != "" || config.GetCertificate == nil || config.MinVersion != tls.VersionTLS13 || len(config.Certificates) != 1 {
t.Fatalf("config=%#v certificate=%q key=%q", config, certificate, key)
}
legacy, err := config.GetCertificate(&tls.ClientHelloInfo{ServerName: "62.238.54.70"})
if err != nil || legacy == nil || len(legacy.Certificate) == 0 {
t.Fatalf("legacy certificate=%#v err=%v", legacy, err)
}
foundALPN := false
for _, protocol := range config.NextProtos {
foundALPN = foundALPN || protocol == "acme-tls/1"
}
if !foundALPN {
t.Fatalf("ACME ALPN missing from %#v", config.NextProtos)
}
if _, _, _, err := controlTLS(t.TempDir(), pkiDir, []string{"62.238.54.70"}, "console.autback.dev", ""); err == nil {
t.Fatal("ACME domain outside AUTBACK_SERVER_NAMES was accepted")
}
}

func TestEndpointUsesThePublicServerNameAndListenerPort(t *testing.T) {
for _, test := range []struct {
name, listen, want string
Expand Down Expand Up @@ -42,9 +74,13 @@ func TestServiceHandlerKeepsTheConsoleOutsideTheConnectControlPlane(t *testing.T
consoleHandler := http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("X-Handler", "console")
})
handler := serviceHandler(controlHandler, consoleHandler)
authHandler := http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("X-Handler", "auth")
})
handler := serviceHandler(controlHandler, consoleHandler, authHandler)
for _, test := range []struct{ path, want string }{
{"/app", "console"}, {"/app/updates", "console"}, {"/rtest.v1.ControlService/GetServiceInfo", "control"}, {"/healthz", "control"},
{"/app", "console"}, {"/app/updates", "console"}, {"/auth/login", "auth"}, {"/auth/cli/start", "auth"},
{"/rtest.v1.ControlService/GetServiceInfo", "control"}, {"/healthz", "control"},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil))
Expand All @@ -54,6 +90,15 @@ func TestServiceHandlerKeepsTheConsoleOutsideTheConnectControlPlane(t *testing.T
}
}

func TestServiceHandlerDoesNotExposeAuthWhenGitHubLoginIsDisabled(t *testing.T) {
handler := serviceHandler(http.NotFoundHandler(), http.NotFoundHandler(), nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("status=%d", response.Code)
}
}

func TestReconcilerRunsImmediatelyWithoutBlockingStartup(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down
Loading