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:
sysops
2026-07-11 23:24:05 +02:00
parent 5b72a7c118
commit 3842c7a534
6 changed files with 396 additions and 1 deletions
+107
View File
@@ -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 {
+2
View File
@@ -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)
+6
View File
@@ -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 {
+8
View File
@@ -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>
+233
View File
@@ -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">&times;</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}}
+40 -1
View File
@@ -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 !== "") {