quorate/services/mirrored-image/k8s/bootstrap/gateway.fsx

577 lines
21 KiB
Text
Raw Normal View History

#load "bootstrap-lib.fsx"
open System
open System.Collections.Generic
open System.IO
open System.Net
open System.Net.Http
open System.Net.Http.Headers
open System.Net.Http.Json
open System.Net.Security
open System.Security.Cryptography
open System.Security.Cryptography.X509Certificates
open System.Text
open System.Text.Json
open System.Text.Json.Nodes
open Bootstrap
type VaultConfig =
{ URL: string
Role: string
JwtPath: string
CaPath: string }
type QuayConfig =
{ URL: string
ZitadelAPI: string
ZitadelIssuer: string
CaPath: string
LoginKey: string }
type KubeConfig =
{ URL: string
TokenPath: string
CaPath: string
Namespace: string
Secret: string }
let private require condition message =
if not condition then
invalidOp message
let private stringNode (value: string) = JsonValue.Create(value) :> JsonNode
let private jsonObject (values: (string * JsonNode) list) =
let result = JsonObject()
for name, value in values do
result[name] <- value
result
let private property (name: string) (root: JsonElement) =
let mutable value = Unchecked.defaultof<JsonElement>
require (root.TryGetProperty(name, &value)) $"response omitted {name}"
value
let private stringProp name root =
let value = property name root
require (value.ValueKind = JsonValueKind.String) $"response {name} is not a string"
value.GetString()
|> Option.ofObj
|> Option.defaultWith (fun () -> invalidOp $"response {name} is empty")
let private readJson operation status (response: HttpResponseMessage) =
use response = response
require (response.StatusCode = status) $"{operation} failed with HTTP {int response.StatusCode}"
use document = JsonDocument.Parse(response.Content.ReadAsStream())
document.RootElement.Clone()
let private expect operation status (response: HttpResponseMessage) =
use response = response
require (response.StatusCode = status) $"{operation} failed with HTTP {int response.StatusCode}"
let private handler caPath allowRedirect =
let handler = new HttpClientHandler(AllowAutoRedirect = allowRedirect)
if not (String.IsNullOrWhiteSpace caPath) then
let root = X509Certificate2.CreateFromPem(File.ReadAllText caPath)
handler.ServerCertificateCustomValidationCallback <-
fun _ certificate peerChain errors ->
if errors = SslPolicyErrors.None then
true
elif isNull certificate || errors <> SslPolicyErrors.RemoteCertificateChainErrors then
false
else
use chain = new X509Chain()
chain.ChainPolicy.TrustMode <- X509ChainTrustMode.CustomRootTrust
chain.ChainPolicy.CustomTrustStore.Add root |> ignore
chain.ChainPolicy.RevocationMode <- X509RevocationMode.NoCheck
if not (isNull peerChain) then
for element in peerChain.ChainElements do
chain.ChainPolicy.ExtraStore.Add element.Certificate |> ignore
chain.Build certificate
handler
let private client caPath allowRedirect =
let client = new HttpClient(handler caPath allowRedirect, true)
client.Timeout <- TimeSpan.FromSeconds 30.
client
let private sessionClient caPath =
let handler = handler caPath false
handler.CookieContainer <- CookieContainer()
let client = new HttpClient(handler, true)
client.Timeout <- TimeSpan.FromSeconds 30.
client
let private sendRequest
(client: HttpClient)
(method: HttpMethod)
(endpoint: string)
(bearer: string option)
(basic: (string * string) option)
(csrf: string option)
(body: JsonNode option)
(host: string option)
(headers: (string * string) list)
=
use message = new HttpRequestMessage(method, endpoint)
host |> Option.iter (fun value -> message.Headers.Host <- value)
headers |> List.iter (fun (name, value) -> message.Headers.Add(name, value))
match bearer, basic with
| Some token, _ -> message.Headers.Authorization <- AuthenticationHeaderValue("Bearer", token)
| _, Some(user, token) ->
let encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{token}"))
message.Headers.Authorization <- AuthenticationHeaderValue("Basic", encoded)
| _ -> ()
csrf |> Option.iter (fun token -> message.Headers.Add("X-CSRF-Token", token))
body
|> Option.iter (fun value ->
let content =
new StringContent(value.ToJsonString(), Encoding.UTF8, "application/json")
content.Headers.ContentType.CharSet <- null
message.Content <- content)
client.Send message
let private request client method endpoint bearer basic csrf body =
sendRequest client method endpoint bearer basic csrf body None []
let private endpoint (root: string) (path: string) =
$"{root.TrimEnd('/')}/{path.TrimStart('/')}"
let private base64Url (content: byte array) =
Convert.ToBase64String content
|> _.TrimEnd('=')
|> _.Replace('+', '-')
|> _.Replace('/', '_')
let private systemToken zitadel (key: string) =
let issued = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
let header = JsonSerializer.SerializeToUtf8Bytes {| alg = "RS256" |} |> base64Url
let claims =
JsonSerializer.SerializeToUtf8Bytes
{| iss = "login-client"
sub = "login-client"
aud = zitadel
iat = issued
exp = issued + 300L |}
|> base64Url
let unsigned = $"{header}.{claims}"
use rsa = RSA.Create()
rsa.ImportFromPem key
let signature =
rsa.SignData(Encoding.UTF8.GetBytes unsigned, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
|> base64Url
$"{unsigned}.{signature}"
let readKubeKeyWith (client: HttpClient) (config: KubeConfig) =
let token = File.ReadAllText(config.TokenPath).Trim()
require (not (String.IsNullOrWhiteSpace token)) "service account token is empty"
let path =
$"api/v1/namespaces/{Uri.EscapeDataString config.Namespace}/secrets/{Uri.EscapeDataString config.Secret}"
let response =
request client HttpMethod.Get (endpoint config.URL path) (Some token) None None None
let data =
readJson "read ZITADEL login key" HttpStatusCode.OK response |> property "data"
let encoded = stringProp "tls.key" data
Encoding.UTF8.GetString(Convert.FromBase64String encoded)
let readKubeKey (config: KubeConfig) =
readKubeKeyWith (client config.CaPath true) config
let private vaultRequest
(client: HttpClient)
(method: HttpMethod)
(endpoint: string)
(token: string)
(body: JsonNode option)
=
use message = new HttpRequestMessage(method, endpoint)
message.Headers.Add("X-Vault-Token", token)
body |> Option.iter (fun value -> message.Content <- JsonContent.Create value)
client.Send message
let makeStoreWith (client: HttpClient) (config: VaultConfig) =
let jwt = File.ReadAllText(config.JwtPath).Trim()
require (not (String.IsNullOrWhiteSpace jwt)) "service account token is empty"
let payload = jsonObject [ "role", stringNode config.Role; "jwt", stringNode jwt ]
let response =
request client HttpMethod.Post (endpoint config.URL "v1/auth/kubernetes/login") None None None (Some payload)
let login = readJson "Vault login" HttpStatusCode.OK response
let token = login |> property "auth" |> stringProp "client_token"
let read (path: string) =
let response =
vaultRequest client HttpMethod.Get (endpoint config.URL $"v1/{path}") token None
let root = readJson "Vault read" HttpStatusCode.OK response
let data = root |> property "data" |> property "data"
data.EnumerateObject()
|> Seq.map (fun item -> item.Name, item.Value.GetString())
|> Seq.choose (fun (name, value) -> value |> Option.ofObj |> Option.map (fun text -> name, text))
|> Map.ofSeq
let write (path: string) (values: Map<string, string>) =
let data = JsonObject()
for KeyValue(name, value) in values do
data[name] <- stringNode value
let payload = jsonObject (List.singleton ("data", data))
let response =
vaultRequest client HttpMethod.Post (endpoint config.URL $"v1/{path}") token (Some payload)
expect "Vault write" HttpStatusCode.OK response
{ Read = read; Write = write }
let makeStore (config: VaultConfig) =
makeStoreWith (client config.CaPath true) config
let private query values =
let pairs = values |> List.map (fun (name, value) -> KeyValuePair(name, value))
use content = new FormUrlEncodedContent(pairs)
content.ReadAsStringAsync().GetAwaiter().GetResult()
let private assignments quayURL (session: Session) =
let client = session.State :?> HttpClient
let response =
request client HttpMethod.Get (endpoint quayURL "/api/v1/user/assignedauthorization") None None None None
readJson "list assignments" HttpStatusCode.OK response
|> property "authorizations"
|> _.EnumerateArray()
|> Seq.map _.Clone()
|> Seq.toList
let private assignment quayURL app items =
let matches =
items
|> List.filter (fun item -> item |> property "application" |> stringProp "clientId" = app.Client)
match matches with
| [] -> None
| [ item ] ->
let scopes =
item
|> property "scopes"
|> _.EnumerateArray()
|> Seq.map (stringProp "scope")
|> Seq.sort
|> Seq.toList
require (scopes = List.sort app.Scopes) "assigned authorization has different scopes"
require
(stringProp "redirectUri" item = endpoint quayURL "/oauth/localapp")
"assigned authorization has a different redirect URI"
require (stringProp "responseType" item = "token") "assigned authorization has a different response type"
Some item
| _ -> invalidOp "multiple assigned authorizations use the same client ID"
let makeQuayWith (adminClient: HttpClient) makeSession (config: QuayConfig) =
let quayURL path = endpoint config.URL path
let zitadelURL path = endpoint config.ZitadelIssuer path
let issuer = Uri config.ZitadelIssuer
let apiRoot = Uri config.ZitadelAPI
let zitadelRequest client method url bearer body =
let source = Uri url
let builder = UriBuilder apiRoot
builder.Path <- source.AbsolutePath
builder.Query <- source.Query.TrimStart('?')
sendRequest
client
method
(builder.Uri.ToString())
bearer
None
None
body
(Some issuer.Host)
[ ("X-Zitadel-Public-Host", issuer.Host) ]
let login user password loginAuth =
let client = makeSession ()
let csrfRoot =
request client HttpMethod.Get (quayURL "/csrf_token") None None None None
let csrf =
readJson "get Quay CSRF token" HttpStatusCode.OK csrfRoot
|> stringProp "csrf_token"
let payload = jsonObject (List.singleton ("kind", stringNode "login"))
let externalResponse =
request
client
HttpMethod.Post
(quayURL "/api/v1/externallogin/zitadel")
None
None
(Some csrf)
(Some payload)
let external = readJson "start Quay login" HttpStatusCode.OK externalResponse
let authURL = stringProp "auth_url" external
use started = zitadelRequest client HttpMethod.Get authURL None None
require
(int started.StatusCode >= 300 && int started.StatusCode < 400)
$"start ZITADEL login failed with HTTP {int started.StatusCode}"
require (not (isNull started.Headers.Location)) "ZITADEL login returned no redirect"
let loginURL = Uri(Uri(zitadelURL "/"), started.Headers.Location)
let requestID = loginURL.Query.TrimStart('?').Split('&')
let requestID =
requestID
|> Array.tryPick (fun item ->
match item.Split('=', 2) with
| [| name; value |] when name = "authRequest" -> Some(Uri.UnescapeDataString value)
| _ -> None)
|> Option.defaultWith (fun () -> invalidOp "ZITADEL redirect omitted authRequest")
let userCheck =
jsonObject (
List.singleton (
"checks",
jsonObject (List.singleton ("user", jsonObject (List.singleton ("loginName", stringNode user))))
)
)
let createdResponse =
zitadelRequest client HttpMethod.Post (zitadelURL "/v2/sessions") (Some loginAuth) (Some userCheck)
let created =
readJson "create ZITADEL session" HttpStatusCode.Created createdResponse
let sessionID = stringProp "sessionId" created
let passwordCheck =
jsonObject (
List.singleton (
"checks",
jsonObject (
List.singleton ("password", jsonObject (List.singleton ("password", stringNode password)))
)
)
)
let checkedResponse =
zitadelRequest
client
HttpMethod.Patch
(zitadelURL $"/v2/sessions/{Uri.EscapeDataString sessionID}")
(Some loginAuth)
(Some passwordCheck)
let passwordResult =
readJson "check ZITADEL password" HttpStatusCode.OK checkedResponse
let sessionToken = stringProp "sessionToken" passwordResult
let finish =
jsonObject (
List.singleton (
"session",
jsonObject [ "sessionId", stringNode sessionID; "sessionToken", stringNode sessionToken ]
)
)
let finishResponse =
zitadelRequest
client
HttpMethod.Post
(zitadelURL $"/v2/oidc/auth_requests/{Uri.EscapeDataString requestID}")
(Some loginAuth)
(Some finish)
let finished = readJson "finish ZITADEL request" HttpStatusCode.OK finishResponse
let callback = stringProp "callbackUrl" finished
use callbackResponse = request client HttpMethod.Get callback None None None None
require
(int callbackResponse.StatusCode >= 200 && int callbackResponse.StatusCode < 400)
$"finish Quay login failed with HTTP {int callbackResponse.StatusCode}"
{ User = user; State = client }
let admin method path token body operation status =
request adminClient method (quayURL path) (Some token) None None body
|> expect operation status
let adminJson method path token body operation =
request adminClient method (quayURL path) (Some token) None None body
|> readJson operation HttpStatusCode.OK
let access adminToken app =
let teamPath =
$"/api/v1/organization/{Uri.EscapeDataString app.Org}/team/mirrored-image"
let team = jsonObject (List.singleton ("role", stringNode "creator"))
admin HttpMethod.Put teamPath adminToken (Some team) "ensure Quay team" HttpStatusCode.OK
let memberPath = $"{teamPath}/members/{Uri.EscapeDataString app.User}"
admin HttpMethod.Put memberPath adminToken None "ensure Quay member" HttpStatusCode.OK
let protoPath = $"/api/v1/organization/{Uri.EscapeDataString app.Org}/prototypes"
let listed =
adminJson HttpMethod.Get protoPath adminToken None "list Quay prototypes"
let exists =
listed
|> property "prototypes"
|> _.EnumerateArray()
|> Seq.exists (fun item ->
let delegateValue = property "delegate" item
stringProp "role" item = "write"
&& stringProp "name" delegateValue = "mirrored-image"
&& stringProp "kind" delegateValue = "team")
if not exists then
let payload =
jsonObject
[ "role", stringNode "write"
"delegate", jsonObject [ "name", stringNode "mirrored-image"; "kind", stringNode "team" ] ]
admin HttpMethod.Post protoPath adminToken (Some payload) "create Quay prototype" HttpStatusCode.OK
let application adminToken app =
let path = $"/api/v1/organization/{Uri.EscapeDataString app.Org}/applications"
let listed = adminJson HttpMethod.Get path adminToken None "list Quay applications"
let redirect = quayURL "/oauth/localapp"
let existing =
listed
|> property "applications"
|> _.EnumerateArray()
|> Seq.tryFind (fun item -> stringProp "name" item = app.Name)
match existing with
| Some item ->
require (stringProp "redirect_uri" item = redirect) "existing application has a different redirect URI"
stringProp "client_id" item
| None ->
let payload =
jsonObject
[ "name", stringNode app.Name
"redirect_uri", stringNode redirect
"application_uri", stringNode redirect ]
adminJson HttpMethod.Post path adminToken (Some payload) "create Quay application"
|> stringProp "client_id"
let assign adminToken session app =
if assignment config.URL app (assignments config.URL session) |> Option.isNone then
let values =
[ "username", app.User
"response_type", "token"
"client_id", app.Client
"scope", String.concat " " app.Scopes
"redirect_uri", quayURL "/oauth/localapp"
"format", "json" ]
let assignPath = quayURL "/oauth/authorize/assignuser"
let url = $"{assignPath}?{query values}"
request adminClient HttpMethod.Post url None (Some("$oauthtoken", adminToken)) None None
|> expect "assign Quay authorization" HttpStatusCode.OK
assignment config.URL app (assignments config.URL session)
|> Option.defaultWith (fun () -> invalidOp "assigned authorization is absent")
|> ignore
let issue session app =
let client = session.State :?> HttpClient
let assigned =
assignment config.URL app (assignments config.URL session)
|> Option.defaultWith (fun () -> invalidOp "assigned authorization is absent")
let scopes =
assigned
|> property "scopes"
|> _.EnumerateArray()
|> Seq.map (stringProp "scope")
|> String.concat " "
let application = property "application" assigned
let values =
[ "response_type", stringProp "responseType" assigned
"client_id", stringProp "clientId" application
"scope", scopes
"redirect_uri", stringProp "redirectUri" assigned
"assignment_uuid", stringProp "uuid" assigned ]
let approvePath = quayURL "/oauth/authorize"
let approveQuery = query (values @ List.singleton ("format", "json"))
let approveURL = $"{approvePath}?{approveQuery}"
let approvalResponse = request client HttpMethod.Get approveURL None None None None
let csrf =
readJson "load Quay authorization" HttpStatusCode.OK approvalResponse
|> stringProp "csrf_token_val"
use message = new HttpRequestMessage(HttpMethod.Post, quayURL "/oauth/authorizeapp")
message.Content <-
new FormUrlEncodedContent(values |> List.map (fun (name, value) -> KeyValuePair(name, value)))
message.Headers.Add("X-CSRF-Token", csrf)
use response = client.Send message
require
(response.StatusCode = HttpStatusCode.Found)
$"issue Quay token failed with HTTP {int response.StatusCode}"
require (not (isNull response.Headers.Location)) "Quay token response omitted redirect"
response.Headers.Location.Fragment.TrimStart('#').Split('&')
|> Array.tryPick (fun item ->
match item.Split('=', 2) with
| [| name; value |] when name = "access_token" -> Some(Uri.UnescapeDataString value)
| _ -> None)
|> Option.defaultWith (fun () -> invalidOp "Quay token response omitted access token")
{ LoginAuth = fun () -> systemToken config.ZitadelIssuer config.LoginKey
Login = login
Access = access
Application = application
Assign = assign
Issue = issue }
let makeQuay (config: QuayConfig) =
makeQuayWith (client config.CaPath false) (fun () -> sessionClient config.CaPath) config