mirror of
https://github.com/ubicloud/ubicloud.git
synced 2025-10-03 21:32:04 +08:00
386 lines
12 KiB
Ruby
386 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "model"
|
|
|
|
require "roda"
|
|
require "tilt"
|
|
require "tilt/erubi"
|
|
require "openssl"
|
|
|
|
class CloverAdmin < Roda
|
|
# :nocov:
|
|
plugin :exception_page if Config.development?
|
|
default_fixed_locals = if Config.production? || ENV["CLOVER_FREEZE"] == "1"
|
|
"()"
|
|
# :nocov:
|
|
else
|
|
"(_no_kw: nil)"
|
|
end
|
|
|
|
plugin :render, views: "views/admin", escape: true, assume_fixed_locals: true, template_opts: {
|
|
chain_appends: !defined?(SimpleCov),
|
|
freeze: true,
|
|
skip_compiled_encoding_detection: true,
|
|
scope_class: self,
|
|
default_fixed_locals:,
|
|
extract_fixed_locals: true
|
|
}
|
|
|
|
plugin :public
|
|
plugin :flash
|
|
plugin :h
|
|
|
|
plugin :content_security_policy do |csp|
|
|
csp.default_src :none
|
|
csp.style_src :self
|
|
csp.img_src :self # /favicon.ico
|
|
csp.script_src :self # webauthn
|
|
csp.form_action :self
|
|
csp.base_uri :none
|
|
csp.frame_ancestors :none
|
|
end
|
|
|
|
plugin :sessions,
|
|
key: "_CloverAdmin.session",
|
|
cookie_options: {secure: !(Config.development? || Config.test?)},
|
|
secret: OpenSSL::HMAC.digest("SHA512", Config.clover_session_secret, "admin-site")
|
|
|
|
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
|
|
plugin :typecast_params do
|
|
ubid_regexp = /\A[a-tv-z0-9]{26}\z/
|
|
|
|
handle_type(:ubid) do
|
|
it if ubid_regexp.match?(it)
|
|
end
|
|
|
|
handle_type(:ubid_uuid) do
|
|
UBID.to_uuid(it) if ubid_regexp.match?(it)
|
|
end
|
|
end
|
|
|
|
plugin :symbol_matchers
|
|
symbol_matcher(:ubid, /([a-tv-z0-9]{26})/)
|
|
|
|
plugin :not_found do
|
|
raise "admin route not handled: #{request.path}" if Config.test? && !ENV["DONT_RAISE_ADMIN_ERRORS"]
|
|
@page_title = "File Not Found"
|
|
view(content: "")
|
|
end
|
|
|
|
plugin :route_csrf do |token|
|
|
flash.now["error"] = "An invalid security token submitted with this request, please try again"
|
|
@page_title = "Invalid Security Token"
|
|
view(content: "")
|
|
end
|
|
|
|
plugin :error_handler do |e|
|
|
raise e if Config.test? && !ENV["DONT_RAISE_ADMIN_ERRORS"]
|
|
Clog.emit("admin route exception") { Util.exception_to_hash(e) }
|
|
@page_title = "Internal Server Error"
|
|
view(content: "")
|
|
end
|
|
|
|
plugin :forme_route_csrf
|
|
Forme.register_config(:clover_admin, base: :default, labeler: :explicit)
|
|
Forme.default_config = :clover_admin
|
|
|
|
def self.create_admin_account(login)
|
|
if Config.production? && defined?(Pry)
|
|
raise "cannot create admin account in production via pry as it would log the password"
|
|
end
|
|
|
|
password = SecureRandom.urlsafe_base64(16)
|
|
password_hash = rodauth.new(nil).password_hash(password)
|
|
DB.transaction do
|
|
id = DB[:admin_account].insert(login:)
|
|
DB[:admin_password_hash].insert(id:, password_hash:)
|
|
end
|
|
Clog.emit("Created admin account") { {admin_account_created: login} }
|
|
password
|
|
end
|
|
|
|
plugin :rodauth, route_csrf: true do
|
|
enable :argon2, :login, :logout, :webauthn, :change_password
|
|
accounts_table :admin_account
|
|
password_hash_table :admin_password_hash
|
|
webauthn_keys_table :admin_webauthn_key
|
|
webauthn_user_ids_table :admin_webauthn_user_id
|
|
login_column :login
|
|
login_redirect do
|
|
uses_two_factor_authentication? ? "/webauthn-auth" : "/webauthn-setup"
|
|
end
|
|
check_csrf? false
|
|
require_bcrypt? false
|
|
title_instance_variable :@page_title
|
|
argon2_secret OpenSSL::HMAC.digest("SHA256", Config.clover_session_secret, "admin-argon2-secret")
|
|
hmac_secret OpenSSL::HMAC.digest("SHA512", Config.clover_session_secret, "admin-rodauth-hmac-secret")
|
|
function_name(&{
|
|
rodauth_get_salt: :rodauth_admin_get_salt,
|
|
rodauth_valid_password_hash: :rodauth_admin_valid_password_hash
|
|
}.to_proc)
|
|
|
|
password_minimum_length 16
|
|
password_maximum_bytes 72
|
|
password_meets_requirements? do |password|
|
|
super(password) && password.match?(/[a-z]/) && password.match?(/[A-Z]/) && password.match?(/[0-9]/)
|
|
end
|
|
end
|
|
|
|
ObjectAction = Data.define(:label, :flash, :action) do
|
|
def self.define(*, &action)
|
|
new(*, action)
|
|
end
|
|
|
|
def call(obj)
|
|
action.call(obj)
|
|
end
|
|
end
|
|
|
|
def self.object_action(...)
|
|
ObjectAction.define(...)
|
|
end
|
|
|
|
OBJECT_ACTIONS = {
|
|
"Account" => {
|
|
"suspend" => object_action("Suspend", "Account suspended", &:suspend)
|
|
},
|
|
"GithubRunner" => {
|
|
"provision" => object_action("Provision Spare Runner", "Spare runner provisioned", &:provision_spare_runner)
|
|
},
|
|
"PostgresResource" => {
|
|
"restart" => object_action("Restart", "Restart scheduled for PostgresResource", &:incr_restart)
|
|
},
|
|
"Strand" => {
|
|
"schedule" => object_action("Schedule Strand to Run Immediately", "Scheduled strand to run immediately") do |obj|
|
|
obj.this.update(schedule: Sequel::CURRENT_TIMESTAMP)
|
|
end
|
|
},
|
|
"Vm" => {
|
|
"restart" => object_action("Restart", "Restart scheduled for Vm", &:incr_restart)
|
|
},
|
|
"VmHost" => {
|
|
"accept" => object_action("Move to Accepting", "Host allocation state changed to accepting") do |obj|
|
|
obj.update(allocation_state: "accepting")
|
|
end,
|
|
"drain" => object_action("Move to Draining", "Host allocation state changed to draining") do |obj|
|
|
obj.update(allocation_state: "draining")
|
|
end,
|
|
"reset" => object_action("Hardware Reset", "Hardware reset scheduled for VmHost", &:incr_hardware_reset),
|
|
"reboot" => object_action("Reboot", "Reboot scheduled for VmHost", &:incr_reboot)
|
|
}
|
|
}.freeze
|
|
OBJECT_ACTIONS.each_value(&:freeze)
|
|
|
|
plugin :autoforme do
|
|
# :nocov:
|
|
register_by_name if Config.development?
|
|
# :nocov:
|
|
|
|
pagination_strategy :filter
|
|
order [:id]
|
|
supported_actions [:browse, :search]
|
|
form_options(wrapper: :div)
|
|
|
|
link = lambda do |obj|
|
|
return "" unless obj
|
|
"<a href=\"/model/#{obj.class}/#{obj.ubid}\">#{Erubi.h(obj.name)}</a>"
|
|
end
|
|
|
|
show_html do |obj, column|
|
|
case column
|
|
when :name
|
|
link.call(obj)
|
|
when :project, :location, :vm_host
|
|
link.call(obj.send(column))
|
|
end
|
|
end
|
|
|
|
column_grep = lambda do |ds, column, value|
|
|
ds.where(Sequel.cast(column, :text).ilike("%#{ds.escape_like(value)}%"))
|
|
end
|
|
|
|
model Firewall do
|
|
eager [:project, :location]
|
|
columns [:name, :project, :location, :description]
|
|
end
|
|
|
|
model Account do
|
|
order Sequel.desc(Sequel[:accounts][:created_at])
|
|
eager_graph [:identities]
|
|
columns [:name, :email, :status_id, :provider_names, :created_at, :suspended_at]
|
|
column_options email: {type: "text"},
|
|
status_id: {type: "select", options: {Unverified: 1, Verified: 2, Closed: 3}, add_blank: true},
|
|
provider_names: {label: "Providers", type: "select", options: ["google", "github"], add_blank: true},
|
|
created_at: {type: "text"},
|
|
suspended_at: {label: "Suspended", type: "boolean", value: nil}
|
|
|
|
column_search_filter do |ds, column, value|
|
|
case column
|
|
when :provider_names
|
|
ds.where(provider: value)
|
|
when :created_at
|
|
column_grep.call(ds, Sequel[:accounts][:created_at], value)
|
|
when :suspended_at
|
|
ds.send((value == "t") ? :exclude : :where, suspended_at: nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
model GithubInstallation do
|
|
order Sequel.desc(:created_at)
|
|
columns [:name, :installation_id, :type, :cache_enabled, :premium_runner_enabled?, :created_at, :allocator_preferences]
|
|
|
|
column_options type: {type: "select", options: ["Organization", "User"], add_blank: true},
|
|
premium_runner_enabled?: {label: "Premium enabled", type: "boolean", value: nil},
|
|
created_at: {type: "text"}
|
|
|
|
column_search_filter do |ds, column, value|
|
|
case column
|
|
when :premium_runner_enabled?
|
|
family_filter = Sequel.pg_jsonb(:allocator_preferences).get("family_filter")
|
|
cond = family_filter.contains(["premium"])
|
|
if value == "t"
|
|
ds.where(cond)
|
|
else
|
|
ds.where(~cond | {family_filter => nil})
|
|
end
|
|
when :allocator_preferences, :created_at
|
|
column_grep.call(ds, column, value)
|
|
end
|
|
end
|
|
end
|
|
|
|
model Vm do
|
|
order Sequel.desc(:created_at)
|
|
eager [:location, :vm_host]
|
|
columns [:name, :display_state, :vm_host, :location, :arch, :boot_image, :family, :vcpus, :created_at]
|
|
column_options display_state: {type: "select", options: ["running", "creating", "starting", "rebooting", "deleting"], add_blank: true},
|
|
arch: {type: "select", options: ["x64", "arm64"], add_blank: true},
|
|
family: {type: "select", options: Option::VmFamilies.map(&:name), add_blank: true},
|
|
vcpus: {type: "number"},
|
|
created_at: {type: "text"}
|
|
|
|
column_search_filter do |ds, column, value|
|
|
if column == :created_at
|
|
column_grep.call(ds, column, value)
|
|
end
|
|
end
|
|
end
|
|
|
|
model VmHost do
|
|
order Sequel[:vm_host][:id]
|
|
eager [:location]
|
|
eager_graph [:sshable]
|
|
columns do |type_symbol, request|
|
|
cs = [:sshable_host, :allocation_state, :arch, :location, :data_center, :family, :total_cores, :total_hugepages_1g]
|
|
cs.prepend(:name) unless type_symbol == :search_form
|
|
cs
|
|
end
|
|
column_options sshable_host: {label: "Sshable", type: :text, value: ""},
|
|
allocation_state: {type: "select", options: ["accepting", "draining", "unprepared"], add_blank: true},
|
|
arch: {type: "select", options: ["x64", "arm64"], add_blank: true},
|
|
family: {type: "select", options: Option::VmFamilies.map(&:name), add_blank: true},
|
|
total_cores: {type: "number"},
|
|
total_hugepages_1g: {type: "number"}
|
|
|
|
column_search_filter do |ds, column, value|
|
|
if column == :sshable_host
|
|
column_grep.call(ds, Sequel[:sshable][:host], value)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
route do |r|
|
|
r.public
|
|
check_csrf!
|
|
r.rodauth
|
|
rodauth.require_authentication
|
|
rodauth.require_two_factor_setup
|
|
|
|
# :nocov:
|
|
r.exception_page_assets if Config.development?
|
|
# :nocov:
|
|
|
|
r.on "model", /([A-Z][a-zA-Z]+)/ do |model_name|
|
|
begin
|
|
@klass = Object.const_get(model_name)
|
|
rescue NameError
|
|
next
|
|
end
|
|
|
|
next unless @klass.is_a?(Class) && @klass < ResourceMethods::InstanceMethods
|
|
|
|
r.get true do
|
|
limit = 101
|
|
ds = @klass.limit(limit).order(:id)
|
|
|
|
if (after = typecast_params.ubid_uuid("after"))
|
|
ds = ds.where { id > after }
|
|
end
|
|
|
|
@objects = ds.all
|
|
|
|
if @objects.length == limit
|
|
@objects.pop
|
|
@after = @objects.last.ubid
|
|
end
|
|
|
|
view("objects")
|
|
end
|
|
|
|
r.on :ubid do |ubid|
|
|
next unless (@obj = @klass[ubid])
|
|
|
|
r.get true do
|
|
view("object")
|
|
end
|
|
|
|
if (actions = OBJECT_ACTIONS[@obj.class.name])
|
|
r.is actions.keys do |key|
|
|
action = actions[key]
|
|
|
|
r.get do
|
|
@label = action.label
|
|
view("object_action")
|
|
end
|
|
|
|
r.post do
|
|
action.call(@obj)
|
|
flash["notice"] = action.flash
|
|
r.redirect("/model/#{@obj.class}/#{ubid}")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
r.on "autoforme" do
|
|
autoforme
|
|
end
|
|
|
|
r.root do
|
|
if (ubid = typecast_params.ubid("ubid")) && (klass = UBID.class_for_ubid(ubid))
|
|
r.redirect("/model/#{klass.name}/#{ubid}")
|
|
elsif typecast_params.nonempty_str("ubid")
|
|
flash.now["error"] = "Invalid ubid provided"
|
|
end
|
|
|
|
@grouped_pages = Page.active.reverse(:created_at, :summary).group_by_vm_host
|
|
@classes = Sequel::Model
|
|
.subclasses
|
|
.map { [it, it.subclasses] }
|
|
.flatten
|
|
.select { it < ResourceMethods::InstanceMethods }
|
|
.sort_by(&:name)
|
|
|
|
view("index")
|
|
end
|
|
|
|
# :nocov:
|
|
if Config.test?
|
|
# :nocov:
|
|
r.get("error") { raise }
|
|
end
|
|
end
|
|
end
|