-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This patch adds an initial `/admin` endpoint that can be accessed with HTTP Basic authentication. The endpoint is enabled by setting the environment variable `JULIA_PKG_SERVER_ADMIN_TOKEN_SHA256` to the SHA256 hash of a token that is used as password for the `admin` user. This patch adds two initial API endpoints: - 'GET /admin': Fetch help message about available admin tasks. - 'GET /admin/logs': Stream log messages until closing the connection. Debug level messages can be enabled by the query parameter `level=debug`.
- Loading branch information
1 parent
ac40a58
commit 6c2b1ad
Showing
9 changed files
with
361 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ authors = ["Stefan Karpinski <[email protected]>", "Elliot Saba <staticfloat@ | |
version = "0.2.0" | ||
|
||
[deps] | ||
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" | ||
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" | ||
FilesystemDatastructures = "89f0c457-83d8-4998-bfff-8b4a338c9833" | ||
Gzip_jll = "be1be57a-8558-53c3-a7e5-50095f79957e" | ||
|
@@ -21,6 +22,7 @@ Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" | |
StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" | ||
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" | ||
Tar = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" | ||
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" | ||
|
||
[compat] | ||
HTTP = "1" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
using Base64: Base64 | ||
using Logging: Logging | ||
using URIs: URIs | ||
|
||
include("admin_logging.jl") | ||
|
||
# ************************************************************************************ | ||
# * IMPORTANT * | ||
# * The /admin endpoint must only be configured if the server is secured with HTTPS. * | ||
# * PkgServer itself only uses HTTP, so this has to be configured externally with * | ||
# * e.g. a TLS terminating reverse proxy. * | ||
# * IMPORTANT * | ||
# ************************************************************************************ | ||
# | ||
# The `/admin` endpoint is enabled by configuring an admin token. The admin token is used as | ||
# the password for the `admin` user with HTTP basic authentication. The token can be any | ||
# string, but it is recommended to generate a long random one. PkgServer only need the | ||
# SHA256 hash of the token. To tell PkgServer about the token use the environment variable | ||
# `JULIA_PKG_SERVER_ADMIN_TOKEN_SHA256`. | ||
# | ||
# The token and the hash can, for example, be setup as with the following commands: | ||
# ``` | ||
# openssl rand 32 \ | ||
# | openssl enc -A -base64 \ | ||
# | tee admin_token.secret \ | ||
# | sha256sum - \ | ||
# | cut -d ' ' -f 1 \ | ||
# | echo "JULIA_PKG_SERVER_ADMIN_TOKEN_SHA256=$(cat -)" >> .env | ||
# ``` | ||
# This i) generates a 32-byte admin token, ii) encodes it as base64, iii) prints the token | ||
# to the file `admin_token.secret`, iv) hashes the token, and v) outputs the resulting | ||
# environment variable to a `.env` file. | ||
|
||
function simple_http_response(http::HTTP.Stream, s::Int, msg::Union{String,Nothing}=nothing) | ||
HTTP.setstatus(http, s) | ||
if msg === nothing | ||
HTTP.setheader(http, "Content-Length" => "0") | ||
HTTP.startwrite(http) | ||
else | ||
HTTP.setheader(http, "Content-Type" => "text/plain") | ||
HTTP.setheader(http, "Content-Length" => string(sizeof(msg))) | ||
HTTP.startwrite(http) | ||
write(http, msg) | ||
end | ||
return nothing | ||
end | ||
|
||
function invalid_auth(http) | ||
msg = "Invalid Authorization header, invalid user, or invalid password.\n" | ||
HTTP.setheader(http, "WWW-Authenticate" => "Basic") | ||
return simple_http_response(http, 401, msg) | ||
end | ||
|
||
# Matches the base64 encoded data in "Basic <base64 data>" | ||
const basic_auth_regex = r"(?<=^Basic )(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$" | ||
const userinfo_regex = r"^(?<username>.+?):(?<token>.+)$" | ||
|
||
function handle_admin(http::HTTP.Stream) | ||
# If no /admin is disabled tell the user | ||
if config.admin_token_sha256 === nothing | ||
msg = "The /admin endpoint is disabled for this server " * | ||
"since no admin token is configured." | ||
return simple_http_response(http, 404, msg) | ||
end | ||
# Check the Authorization header and extract username and token | ||
auth = HTTP.header(http, "Authorization", "") | ||
m = match(basic_auth_regex, auth) | ||
m === nothing && return invalid_auth(http) | ||
userinfo = String(Base64.base64decode(m.match::AbstractString)) | ||
m = match(userinfo_regex, userinfo) | ||
m === nothing && return invalid_auth(http) | ||
# We have a username and token; verify | ||
if !(m["username"]::AbstractString == "admin" && | ||
bytes2hex(SHA.sha256(m["token"]::AbstractString)) == config.admin_token_sha256::String) | ||
return invalid_auth(http) | ||
end | ||
# All good, open sesame! | ||
return handle_admin_authenticated(http) | ||
end | ||
|
||
# If we end up here then the request is authenticated | ||
function handle_admin_authenticated(http::HTTP.Stream) | ||
method = http.message.method | ||
uri = URIs.URI(http.message.target) | ||
if method == "GET" && uri.path == "/admin" | ||
admin_help = """ | ||
Welcome to the /admin endpoint of the package server. | ||
The following admin tasks are currently implemented: | ||
# `GET /admin` | ||
Fetch this message. | ||
# `GET /admin/logs` | ||
Stream log messages until the connection is closed. Debug level log messages are | ||
enabled with the query parameter `level=debug` (default is `level=info`). Color can | ||
be disabled with the query parameter `color=false` (default is `color=true`). | ||
""" | ||
return simple_http_response(http, 200, admin_help) | ||
elseif method == "GET" && uri.path == "/admin/logs" | ||
# uri = URIs.URI(http.message.target) | ||
params = URIs.queryparams(uri) | ||
color = get(params, "color", "true") == "true" | ||
req_level = get(params, "level", "info") == "debug" ? Logging.Debug : Logging.Info | ||
HTTP.setheader(http, "Content-Type" => "text/plain") | ||
HTTP.startwrite(http) | ||
attach(ADMIN_LOGGER) do level, message | ||
level < req_level && return | ||
write(http, color ? message : remove_colors(message)) | ||
end | ||
else | ||
msg = "Unknown /admin endpoint '$(method) $(uri.path)'\n" | ||
simple_http_response(http, 404, msg) | ||
end | ||
return | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
using Logging: Logging | ||
using LoggingExtras: LoggingExtras | ||
|
||
# TODO: Perhaps all of this can be upstreamed to LoggingExtras as an AttachableLogger. | ||
|
||
mutable struct AdminLogger <: Logging.AbstractLogger | ||
const lock::ReentrantLock | ||
const active_clients::Base.IdSet{Channel{Tuple{Logging.LogLevel, String}}} | ||
@atomic n_clients::Int | ||
function AdminLogger() | ||
return new(ReentrantLock(), Base.IdSet{Channel{Tuple{Logging.LogLevel, String}}}(), 0) | ||
end | ||
end | ||
|
||
const ADMIN_LOGGER = AdminLogger() | ||
|
||
# Level filtering is done later in the pipeline | ||
Logging.min_enabled_level(::AdminLogger) = Logging.BelowMinLevel | ||
|
||
Logging.catch_exceptions(::AdminLogger) = true | ||
|
||
function root_module(m::Module) | ||
while m !== parentmodule(m) | ||
m = parentmodule(m) | ||
end | ||
return m | ||
end | ||
|
||
# Only log if we have clients and if the source module is not HTTP | ||
function Logging.shouldlog(admin_logger::AdminLogger, _, mod, _...) | ||
return @atomic(admin_logger.n_clients) > 0 && root_module(mod) != HTTP | ||
end | ||
|
||
# For every log message we stringify once (using ConsoleLogger's format for | ||
# now) and then send to all the active clients | ||
function Logging.handle_message(admin_logger::AdminLogger, level, args...; kwargs...) | ||
@atomic(admin_logger.n_clients) == 0 && return | ||
# Stringify the message using the ConsoleLogger's format | ||
io = IOBuffer() | ||
ioc = IOContext(io, :color => true) | ||
tmp_logger = Logging.ConsoleLogger(ioc, Logging.BelowMinLevel) | ||
Logging.handle_message(tmp_logger, level, args...; kwargs...) | ||
msg = String(take!(io)) | ||
# The work for each client is just a put! on an infinitely sized channel which | ||
# should i) be quick (holding the lock is fine) and ii) be non-blocking | ||
@lock admin_logger.lock begin | ||
for client in admin_logger.active_clients | ||
try | ||
put!(client, (level, msg)) | ||
catch | ||
# The only failure mode above should be if the channel is closed already | ||
@assert !isopen(client) | ||
# close(client) | ||
end | ||
end | ||
end | ||
return nothing | ||
end | ||
|
||
# Used to store the previous global logging configuration when installing the | ||
# admin logger so that it can be restored later | ||
PREVIOUS_GLOBAL_LOGGER::Union{Nothing, Logging.AbstractLogger} = nothing | ||
|
||
function attach(f::Function, admin_logger::AdminLogger) | ||
# Create the client channel with the callback function `f` | ||
taskref = Ref{Task}() | ||
client = Channel{Tuple{Logging.LogLevel, String}}(Inf; taskref=taskref) do c | ||
for (level, msg) in c | ||
try | ||
f(level, msg) | ||
catch _ | ||
# TODO: Only swallow write errors? | ||
close(c) | ||
end | ||
end | ||
end | ||
# Take the lock and attach the client to the logger | ||
@lock admin_logger.lock begin | ||
# Make sure the logger is installed in the global logger. We could leave it | ||
# installed at all times, but since we expect to have clients attached quite rarely | ||
# we do this little dance to not cause any overhead during normal operation. | ||
if PREVIOUS_GLOBAL_LOGGER === nothing | ||
global PREVIOUS_GLOBAL_LOGGER = Logging.global_logger( | ||
LoggingExtras.TeeLogger(admin_logger, Logging.global_logger()), | ||
) | ||
end | ||
# Add the client | ||
push!(admin_logger.active_clients, client) | ||
@atomic admin_logger.n_clients = length(admin_logger.active_clients) | ||
end | ||
# Block until the client is finished. | ||
wait(taskref[]) | ||
@assert !isopen(client) | ||
# Client has disconnected | ||
@lock admin_logger.lock begin | ||
# Detach this (and any other closed) clients from the logger | ||
filter!(isopen, admin_logger.active_clients) | ||
@atomic admin_logger.n_clients = length(admin_logger.active_clients) | ||
# If there are no active clients the admin logger can be uninstalled | ||
if @atomic(admin_logger.n_clients) == 0 | ||
@assert PREVIOUS_GLOBAL_LOGGER !== nothing | ||
Logging.global_logger(PREVIOUS_GLOBAL_LOGGER) | ||
global PREVIOUS_GLOBAL_LOGGER = nothing | ||
end | ||
end | ||
return | ||
end | ||
|
||
function remove_colors(str::String) | ||
io = IOBuffer(; sizehint = sizeof(str)) | ||
for x in Iterators.filter(x -> x isa Char, Iterators.map(last, Base.ANSIIterator(str))) | ||
write(io, x) | ||
end | ||
return String(take!(io)) | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.