Add server creation UI + user-server access assignment (step 3)
New admin-only page at /servers-settings (templates/servers.html) lists servers and creates new ones via POST /servers (ID/name/interface/ addresses/port, key pair generated server-side). Nav gets a "Servers" link. templates/users_settings.html gains a multi-select "Server Access" field wired to the server_ids support added to create-user/update-user in the previous commit, so admins can now actually assign non-admin users to specific servers through the UI.
This commit is contained in:
@@ -199,6 +199,15 @@ func UsersSettings() echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ServersPage renders the server list/create page
|
||||
func ServersPage() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "servers.html", map[string]interface{}{
|
||||
"baseData": model.BaseData{Active: "servers", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUser to update user information
|
||||
func UpdateUser(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
@@ -256,6 +265,19 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
|
||||
user.Admin = admin
|
||||
}
|
||||
|
||||
// only an admin may change which servers a user can access
|
||||
if isAdmin(c) {
|
||||
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
|
||||
serverIDs := make([]string, 0, len(rawIDs))
|
||||
for _, v := range rawIDs {
|
||||
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
|
||||
serverIDs = append(serverIDs, id)
|
||||
}
|
||||
}
|
||||
user.ServerIDs = serverIDs
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(previousUsername); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||
}
|
||||
@@ -308,6 +330,14 @@ func CreateUser(db store.IStore) echo.HandlerFunc {
|
||||
|
||||
user.Admin = admin
|
||||
|
||||
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
|
||||
for _, v := range rawIDs {
|
||||
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
|
||||
user.ServerIDs = append(user.ServerIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.SaveUser(user); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||
}
|
||||
@@ -476,6 +506,83 @@ func GetServerClients(db store.IStore) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// CreateServer handler creates a new WireGuard server (step 3 of the
|
||||
// multi-server extension). Admin-only. Generates a fresh key pair,
|
||||
// validates the ID/interface name/subnet, and stores the server plus its
|
||||
// per-server settings.
|
||||
func CreateServer(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
type createServerRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Interface string `json:"interface"`
|
||||
Addresses []string `json:"addresses"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
}
|
||||
var req createServerRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
if !util.ValidateRecordID(req.ID) {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid server ID (letters, digits, - and _ only)"})
|
||||
}
|
||||
if !util.ValidateInterfaceName(req.Interface) {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid interface name (max 15 chars, letters/digits/-/_ only)"})
|
||||
}
|
||||
if !util.ValidateServerAddresses(req.Addresses) {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Address ranges must be in CIDR format"})
|
||||
}
|
||||
if req.ListenPort <= 0 || req.ListenPort > 65535 {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid listen port"})
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = req.ID
|
||||
}
|
||||
|
||||
if _, err := db.GetServerByID(req.ID); err == nil {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "A server with this ID already exists"})
|
||||
}
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
log.Error("Cannot generate wireguard key pair: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
||||
}
|
||||
|
||||
server := model.Server{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
KeyPair: &model.ServerKeypair{
|
||||
PrivateKey: key.String(),
|
||||
PublicKey: key.PublicKey().String(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
Interface: &model.ServerInterface{
|
||||
Name: req.Interface,
|
||||
Addresses: req.Addresses,
|
||||
ListenPort: req.ListenPort,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.CreateServer(server); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create server: %v", err)})
|
||||
}
|
||||
|
||||
settings := model.ServerSetting{
|
||||
ConfigFilePath: fmt.Sprintf("/etc/wireguard/%s.conf", req.Interface),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := db.SaveServerSettings(req.ID, settings); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Server created but settings failed: %v", err)})
|
||||
}
|
||||
|
||||
log.Infof("Created server %s (%s)", req.ID, req.Name)
|
||||
return c.JSON(http.StatusOK, server)
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient handler
|
||||
func NewClient(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
||||
@@ -251,7 +251,9 @@ func main() {
|
||||
app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
|
||||
app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||
app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession)
|
||||
app.GET(util.BasePath+"/servers-settings", handler.ServersPage(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
|
||||
app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession)
|
||||
app.POST(util.BasePath+"/servers", handler.CreateServer(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||
app.GET(util.BasePath+"/servers/:id/clients", handler.GetServerClients(db), handler.ValidSession, handler.RequireServerAccess(db))
|
||||
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
|
||||
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
|
||||
|
||||
@@ -111,6 +111,11 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tmplServersString, err := util.StringFromEmbedFile(tmplDir, "servers.html")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// create template list
|
||||
funcs := template.FuncMap{
|
||||
"StringsJoin": strings.Join,
|
||||
@@ -125,6 +130,7 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
|
||||
templates["status.html"] = template.Must(template.New("status").Funcs(funcs).Parse(tmplBaseString + tmplStatusString))
|
||||
templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString))
|
||||
templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
|
||||
templates["servers.html"] = template.Must(template.New("servers").Funcs(funcs).Parse(tmplBaseString + tmplServersString))
|
||||
|
||||
lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
|
||||
if err != nil {
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
|
||||
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{.basePath}}/servers-settings" class="nav-link {{if eq .baseData.Active "servers" }}active{{end}}">
|
||||
<i class="nav-icon fas fa-server"></i>
|
||||
<p>
|
||||
Servers
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{.basePath}}/global-settings" class="nav-link {{if eq .baseData.Active "global-settings" }}active{{end}}">
|
||||
<i class="nav-icon fas fa-cog"></i>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{{define "title"}}
|
||||
Servers
|
||||
{{end}}
|
||||
|
||||
{{define "top_css"}}
|
||||
{{end}}
|
||||
|
||||
{{define "username"}}
|
||||
{{ .username }}
|
||||
{{end}}
|
||||
|
||||
{{define "page_title"}}
|
||||
Servers
|
||||
{{end}}
|
||||
|
||||
{{define "page_content"}}
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="row" id="servers-list">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal fade" id="modal_new_server">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Add new server</h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form name="frm_new_server" id="frm_new_server">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="_server_id" class="control-label">ID</label>
|
||||
<input type="text" class="form-control" id="_server_id" name="_server_id"
|
||||
placeholder="e.g. wg-home">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="_server_name" class="control-label">Name</label>
|
||||
<input type="text" class="form-control" id="_server_name" name="_server_name"
|
||||
placeholder="e.g. WGhome">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="_server_interface" class="control-label">Interface</label>
|
||||
<input type="text" class="form-control" id="_server_interface" name="_server_interface"
|
||||
placeholder="e.g. wg-home">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="_server_addresses" class="control-label">Addresses</label>
|
||||
<input type="text" class="form-control" id="_server_addresses" name="_server_addresses"
|
||||
placeholder="e.g. 10.20.22.0/24, fd00::/64">
|
||||
<small class="form-text text-muted">Comma-separated list of address ranges.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="_server_listen_port" class="control-label">Listen Port</label>
|
||||
<input type="text" class="form-control" id="_server_listen_port" name="_server_listen_port"
|
||||
placeholder="e.g. 51822">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<!-- /.modal -->
|
||||
{{end}}
|
||||
|
||||
{{define "bottom_js"}}
|
||||
<script>
|
||||
function renderServersList(data) {
|
||||
$.each(data, function (index, obj) {
|
||||
const addresses = (obj.Interface && obj.Interface.addresses) ? obj.Interface.addresses.join(", ") : "";
|
||||
const listenPort = obj.Interface ? obj.Interface.listen_port : "";
|
||||
const interfaceName = obj.Interface ? obj.Interface.name : "";
|
||||
|
||||
let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="server_${obj.id}">
|
||||
<div class="info-box">
|
||||
<div class="info-box-content">
|
||||
<div class="btn-group">
|
||||
<a href="{{.basePath}}/servers/${obj.id}/clients" class="btn btn-outline-primary btn-sm">Manage clients</a>
|
||||
</div>
|
||||
<hr>
|
||||
<span class="info-box-text"><i class="fas fa-server"></i> ${obj.name}</span>
|
||||
<span class="info-box-text"><i class="fas fa-fingerprint"></i> ID: ${obj.id}</span>
|
||||
<span class="info-box-text"><i class="fas fa-ethernet"></i> Interface: ${interfaceName}</span>
|
||||
<span class="info-box-text"><i class="fas fa-plug"></i> Listen Port: ${listenPort}</span>
|
||||
<span class="info-box-text"><i class="fas fa-map-marker-alt"></i> Addresses: ${addresses}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
$('#servers-list').append(html);
|
||||
});
|
||||
}
|
||||
|
||||
function populateServersList() {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/servers',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
renderServersList(data);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// load server list
|
||||
$(document).ready(function () {
|
||||
populateServersList();
|
||||
let newServerHtml = '<div class="col-sm-2 offset-md-4" style=" text-align: right;">' +
|
||||
'<button style="" id="btn_new_server" type="button" class="btn btn-outline-primary btn-sm" ' +
|
||||
'data-toggle="modal" data-target="#modal_new_server">' +
|
||||
'<i class="nav-icon fas fa-plus"></i> New Server</button></div>';
|
||||
$('h1').parents(".row").append(newServerHtml);
|
||||
})
|
||||
|
||||
// New server modal event: reset the form each time it is opened
|
||||
$(document).ready(function () {
|
||||
$("#modal_new_server").on('show.bs.modal', function (event) {
|
||||
let modal = $(this);
|
||||
modal.find("#_server_id").val("");
|
||||
modal.find("#_server_name").val("");
|
||||
modal.find("#_server_interface").val("");
|
||||
modal.find("#_server_addresses").val("");
|
||||
modal.find("#_server_listen_port").val("");
|
||||
});
|
||||
});
|
||||
|
||||
function submitNewServer() {
|
||||
const id = $("#_server_id").val();
|
||||
const name = $("#_server_name").val();
|
||||
const iface = $("#_server_interface").val();
|
||||
const addresses = $("#_server_addresses").val().split(",").map(function (a) {
|
||||
return a.trim();
|
||||
}).filter(function (a) {
|
||||
return a !== "";
|
||||
});
|
||||
const listen_port = parseInt($("#_server_listen_port").val(), 10);
|
||||
|
||||
const data = {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"interface": iface,
|
||||
"addresses": addresses,
|
||||
"listen_port": listen_port
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/servers',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: function (data) {
|
||||
$("#modal_new_server").modal('hide');
|
||||
toastr.success("Created server successfully");
|
||||
location.reload();
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
$.validator.setDefaults({
|
||||
submitHandler: function (form) {
|
||||
submitNewServer();
|
||||
}
|
||||
});
|
||||
$("#frm_new_server").validate({
|
||||
rules: {
|
||||
_server_id: {
|
||||
required: true
|
||||
},
|
||||
_server_name: {
|
||||
required: true
|
||||
},
|
||||
_server_interface: {
|
||||
required: true
|
||||
},
|
||||
_server_listen_port: {
|
||||
required: true,
|
||||
digits: true,
|
||||
range: [1, 65535]
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
_server_id: {
|
||||
required: "Please enter a server ID"
|
||||
},
|
||||
_server_name: {
|
||||
required: "Please enter a server name"
|
||||
},
|
||||
_server_interface: {
|
||||
required: "Please enter an interface name"
|
||||
},
|
||||
_server_listen_port: {
|
||||
required: "Please enter a port",
|
||||
digits: "Port must be an integer",
|
||||
range: "Port must be in range 1..65535"
|
||||
}
|
||||
},
|
||||
errorElement: 'span',
|
||||
errorPlacement: function (error, element) {
|
||||
error.addClass('invalid-feedback');
|
||||
element.closest('.form-group').append(error);
|
||||
},
|
||||
highlight: function (element, errorClass, validClass) {
|
||||
$(element).addClass('is-invalid');
|
||||
},
|
||||
unhighlight: function (element, errorClass, validClass) {
|
||||
$(element).removeClass('is-invalid');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -53,6 +53,12 @@ Users Settings
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="_server_ids" class="control-label">Server Access</label>
|
||||
<select multiple class="form-control" id="_server_ids" name="_server_ids">
|
||||
</select>
|
||||
<small class="form-text text-muted">Servers this user (if non-admin) may access. Admins always have access to all servers.</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
@@ -163,6 +169,32 @@ Users Settings
|
||||
const button = $(event.relatedTarget);
|
||||
const user_name = button.data('username');
|
||||
|
||||
// populate the server access select with the current list of servers
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/servers',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (servers) {
|
||||
const select = modal.find("#_server_ids");
|
||||
select.empty();
|
||||
$.each(servers, function (index, srv) {
|
||||
select.append($('<option>').val(srv.id).text(srv.name + " (" + srv.id + ")"));
|
||||
});
|
||||
|
||||
// if editing an existing user, pre-select their granted servers now that
|
||||
// the option list exists
|
||||
if (user_name !== "") {
|
||||
select.val(select.data('preselect') || []);
|
||||
}
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
|
||||
// update user modal data
|
||||
if (user_name !== "") {
|
||||
$.ajax({
|
||||
@@ -180,6 +212,10 @@ Users Settings
|
||||
modal.find("#_user_password").val("");
|
||||
modal.find("#_user_password").prop("placeholder", "Leave empty to keep the password unchanged")
|
||||
modal.find("#_admin").prop("checked", user.admin);
|
||||
// remember the granted server ids so the select can pre-select them
|
||||
// once its options have been populated (see the servers ajax above)
|
||||
modal.find("#_server_ids").data('preselect', user.server_ids || []);
|
||||
modal.find("#_server_ids").val(user.server_ids || []);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
@@ -193,6 +229,7 @@ Users Settings
|
||||
modal.find("#_user_password").val("");
|
||||
modal.find("#_user_password").prop("placeholder", "")
|
||||
modal.find("#_admin").prop("checked", false);
|
||||
modal.find("#_server_ids").data('preselect', []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -205,11 +242,13 @@ Users Settings
|
||||
if ($("#_admin").is(':checked')) {
|
||||
admin = true;
|
||||
}
|
||||
const server_ids = $("#_server_ids").val() || [];
|
||||
const data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"previous_username": previous_username,
|
||||
"admin": admin
|
||||
"admin": admin,
|
||||
"server_ids": server_ids
|
||||
};
|
||||
|
||||
if (previous_username !== "") {
|
||||
|
||||
Reference in New Issue
Block a user