hnakra/rcon/rcon.go

354 lines
7.7 KiB
Go
Raw Normal View History

2023-05-25 16:08:56 -06:00
// Package rcon provices a password-protected remote web interface for managing
// a Hnakra router. For a user to be able to access the control panel, the user
// entry in the configuration file must have the special string (rcon) listed as
// an allowed pattern.
package rcon
import "io"
import "fmt"
import "net/url"
import "net/http"
import "hnakra/config"
import "hnakra/router"
import "hnakra/rotate"
import "hnakra/protocol"
const css = `
body { background-color: #161815; color: #aea894; margin: 3em; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.2em; }
a { color: #69905f; }
table { border-collapse: collapse; }
th, td {
border: 1px solid #272d24;
padding: 0.5em;
text-align: left;
vertical-align: top;
}`
type Rcon struct {
root string
config config.Config
router *router.Router
mux *http.ServeMux
lines []string
lineWriter io.Writer
}
func New (root string) *Rcon {
if len(root) > 0 && root[len(root) - 1] != '/' {
root += "/"
}
rcon := &Rcon {
root: root,
mux: http.NewServeMux(),
}
rcon.lineWriter = rotate.LineWriter(rcon.pushLine)
rcon.mux.HandleFunc(root + "", rcon.serveMainMenu)
rcon.mux.HandleFunc(root + "log", rcon.serveLog)
rcon.mux.HandleFunc(root + "services", rcon.serveServices)
rcon.mux.HandleFunc(root + "config", rcon.serveConfig)
rcon.mux.HandleFunc(root + "details", rcon.serveServiceDetails)
return rcon
}
func (rcon *Rcon) SetConfig (config config.Config) {
rcon.config = config
}
func (rcon *Rcon) SetRouter (router *router.Router) {
rcon.router = router
}
func (rcon *Rcon) pushLine (buffer []byte) (n int, err error) {
const LEN_CAP = 128
n = len(buffer)
line := string(buffer)
rcon.lines = append(rcon.lines, line)
if len(rcon.lines) > LEN_CAP {
rcon.lines = rcon.lines[len(rcon.lines) - LEN_CAP:]
}
return
}
func (rcon *Rcon) Write (buffer []byte) (int, error) {
return rcon.lineWriter.Write(buffer)
}
func (rcon *Rcon) ServeHTTP (res http.ResponseWriter, req *http.Request) {
// disallow accessing rcon over an insecure connection
if req.TLS == nil {
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusTeapot)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>HTTP Not Allowed</title>
<style>` + css + `</style>
</head>
<body>
<h1>HTTP Not Allowed</h1>
<p>RCON is not available over HTTP for security reasons.
Please try again with HTTPS.</p>
</body>
</html>`))
return
}
fail := func () {
res.Header().Add("WWW-Authenticate", "Basic realm=rcon")
res.WriteHeader(http.StatusUnauthorized)
}
// need authentication for accessing rcon menu
name, key, ok := req.BasicAuth()
if ok {
user := rcon.config.User(name)
if !user.Validate([]byte(key)) { fail(); return }
} else {
fail(); return
}
// if the user is authorized, serve page as normal
rcon.mux.ServeHTTP(res, req)
}
func (rcon *Rcon) serveMainMenu (res http.ResponseWriter, req *http.Request) {
if req.URL.Path != rcon.root {
http.NotFoundHandler().ServeHTTP(res, req)
return
}
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusFound)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>RCON Menu</title>
<style>` + css + `</style>
</head>
<body>
<h1>RCON Menu</h1>
<ul>
<li><a href=log>log</a></li>
<li><a href=services>services</a></li>
<li><a href=config>config</a></li>
</ul>
</body>
</html>`))
}
func (rcon *Rcon) serveLog (res http.ResponseWriter, req *http.Request) {
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusFound)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>Router Log</title>
<style>` + css + `</style>
</head>
<body>
<h1>Router Log</h1><pre>`))
for _, line := range rcon.lines {
res.Write([]byte(line))
}
res.Write([]byte (
`</pre></body>
</html>`))
}
func (rcon *Rcon) serveServices (res http.ResponseWriter, req *http.Request) {
if rcon.router == nil {
rcon.serveRouterOffline(res, req)
return
}
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusFound)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>Connected Services</title>
<style>` + css + `</style>
</head>
<body>
<h1>Connected Services</h1>
<table>
<thead>
<tr>
<th>user</th>
<th>name</th>
<th>description</th>
<th>pattern</th>
</tr>
</thead>
<tbody>
`))
rcon.router.OverServices(func (service *router.Service) bool {
res.Write([]byte(fmt.Sprintf (
`<tr><td>%s</td><td><a href="details?service=%s">%s</a></td><td>%s</td><td>%s</td></tr>`,
service.User(),
service.Name(),
service.Name(),
service.Description(),
service.Pattern())))
return true
})
res.Write([]byte (
`</tbody></table></body>
</html>`))
}
func (rcon *Rcon) serveConfig (res http.ResponseWriter, req *http.Request) {
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusFound)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>Router Config</title>
<style>` + css + `</style>
</head>
<body>
<h1>Router Config</h1>
<h2>Users</h2>
<table>
<thead>
<tr>
<th>name</th>
<th>rcon</th>
<th>patterns</th>
</tr>
</thead>
<tbody>`))
rcon.config.OverUsers(func (name string, user config.User) bool {
patterns := ""
user.OverPatterns (func (pattern string) bool {
if patterns != "" {
patterns += " "
}
patterns += pattern
return true
})
res.Write([]byte(fmt.Sprintf (
"<tr><td>%s</td><td>%v</td><td>%s</td></tr>",
name, user.RconAllow(), patterns)))
return true
})
res.Write([]byte(`</tbody></table><h2>Aliases</h2>`))
fallback := rcon.config.AliasFallback()
if fallback != "" {
res.Write([]byte(`<p>Fallback: ` + fallback + `</p>`))
}
res.Write([]byte(`
<table>
<thead>
<tr>
<th>alias</th>
<th>target</th>
</tr>
</thead>
<tbody>`))
rcon.config.OverAliases(func (alias, target string) bool {
res.Write([]byte(fmt.Sprintf (
"<tr><td>%s</td><td>%s</td></tr>",
alias, target)))
return true
})
res.Write([]byte (
`</tbody></table>
<h2>Protocols</h2>
<table>
<thead>
<tr>
<th>scheme</th>
<th>port</th>
</tr>
</thead>
<tbody>`))
listProtocol := func (protocol string, port int) {
res.Write([]byte(fmt.Sprintf (
"<tr><td>%s</td><td>%d</td></tr>",
protocol, port)))
}
if rcon.config.HTTPSEnable() {
listProtocol("https", rcon.config.HTTPSPort())
}
if rcon.config.HTTPEnable() {
listProtocol("http", rcon.config.HTTPPort())
}
if rcon.config.GeminiEnable() {
listProtocol("gemini", rcon.config.GeminiPort())
}
res.Write([]byte(`</tbody></table></body></html>`))
}
func (rcon *Rcon) serveServiceDetails (res http.ResponseWriter, req *http.Request) {
name := req.URL.Query().Get("service")
service := rcon.router.Service(name)
if service == nil {
http.NotFoundHandler().ServeHTTP(res, req)
return
}
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusFound)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>Details of ` + service.Name() + `</title>
<style>` + css + `</style>
</head>
<body>
<h1>Details of ` + service.Name() + `</h1>
<h2>Requests</h2>
<table>
<thead>
<tr>
<th>id</th>
<th>url</th>
</tr>
</thead>
<tbody>`))
service.OverRequests(func (id protocol.ID, url *url.URL) bool {
res.Write([]byte(fmt.Sprintf (
"<tr><td>%v</td><td>%v</td></tr>",
id, url)))
return true
})
res.Write([]byte(`</tbody></table></body></html>`))
}
func (rcon *Rcon) serveRouterOffline (res http.ResponseWriter, req *http.Request) {
res.Header().Add("content-type", "text/html")
res.WriteHeader(http.StatusNotFound)
res.Write([]byte (
`<!DOCTYPE html>
<html>
<head>
<title>Router Offline</title>
<style>` + css + `</style>
</head>
<body>
<h1>Router Offline</h1>
<p>The router has not been started yet.</p>
</body>
</html>`))
}