initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
func apiPutRaw(t *testing.T, url, rawBody, token string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte(rawBody)))
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAPI_UnauthorizedEndpoints(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
paths := []string{
|
||||
"/api/proxy-hosts",
|
||||
"/api/access-lists",
|
||||
"/api/streams",
|
||||
"/api/certificates",
|
||||
"/api/redirection-hosts",
|
||||
"/api/dead-hosts",
|
||||
"/api/settings",
|
||||
"/api/audit-logs",
|
||||
"/api/dashboard",
|
||||
"/api/users",
|
||||
"/api/users/me",
|
||||
}
|
||||
for _, path := range paths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
assertStatus(t, apiGet(t, env.BaseURL+path, ""), http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_BadIDBranches(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"proxy-host get", http.MethodGet, "/api/proxy-hosts/nope", http.StatusBadRequest},
|
||||
{"proxy-host put", http.MethodPut, "/api/proxy-hosts/nope", http.StatusBadRequest},
|
||||
{"proxy-host delete", http.MethodDelete, "/api/proxy-hosts/bad", http.StatusBadRequest},
|
||||
{"proxy-host enable", http.MethodPost, "/api/proxy-hosts/x/enable", http.StatusBadRequest},
|
||||
{"proxy-host disable", http.MethodPost, "/api/proxy-hosts/x/disable", http.StatusBadRequest},
|
||||
|
||||
{"access-list put", http.MethodPut, "/api/access-lists/abc", http.StatusBadRequest},
|
||||
{"access-list delete", http.MethodDelete, "/api/access-lists/abc", http.StatusBadRequest},
|
||||
|
||||
{"stream put", http.MethodPut, "/api/streams/nope", http.StatusBadRequest},
|
||||
{"stream delete", http.MethodDelete, "/api/streams/bad", http.StatusBadRequest},
|
||||
{"stream enable", http.MethodPost, "/api/streams/x/enable", http.StatusBadRequest},
|
||||
{"stream disable", http.MethodPost, "/api/streams/x/disable", http.StatusBadRequest},
|
||||
|
||||
{"cert delete", http.MethodDelete, "/api/certificates/bad", http.StatusBadRequest},
|
||||
|
||||
{"redir put", http.MethodPut, "/api/redirection-hosts/nope", http.StatusBadRequest},
|
||||
{"redir delete", http.MethodDelete, "/api/redirection-hosts/bad", http.StatusBadRequest},
|
||||
{"redir disable", http.MethodPost, "/api/redirection-hosts/x/disable", http.StatusBadRequest},
|
||||
|
||||
{"dead put", http.MethodPut, "/api/dead-hosts/nope", http.StatusBadRequest},
|
||||
{"dead delete", http.MethodDelete, "/api/dead-hosts/bad", http.StatusBadRequest},
|
||||
{"dead disable", http.MethodPost, "/api/dead-hosts/x/disable", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var resp *http.Response
|
||||
switch tc.method {
|
||||
case http.MethodGet:
|
||||
resp = apiGet(t, env.BaseURL+tc.path, token)
|
||||
case http.MethodPut:
|
||||
resp = apiPut(t, env.BaseURL+tc.path, map[string]any{"name": "x"}, token)
|
||||
case http.MethodDelete:
|
||||
resp = apiDelete(t, env.BaseURL+tc.path, token)
|
||||
default:
|
||||
resp = apiPost(t, env.BaseURL+tc.path, nil, token)
|
||||
}
|
||||
assertStatus(t, resp, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_NotFoundEnableDisable(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
cases := []string{
|
||||
"/api/proxy-hosts/9999/disable",
|
||||
"/api/streams/9999/enable",
|
||||
"/api/streams/9999/disable",
|
||||
"/api/redirection-hosts/9999/enable",
|
||||
"/api/dead-hosts/9999/disable",
|
||||
"/api/dead-hosts/9999/enable",
|
||||
}
|
||||
for _, path := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
assertStatus(t, apiPost(t, env.BaseURL+path, nil, token), http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_MalformedJSONOnCreateAndUpdate(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
createPaths := []string{
|
||||
"/api/proxy-hosts",
|
||||
"/api/access-lists",
|
||||
"/api/streams",
|
||||
"/api/redirection-hosts",
|
||||
"/api/dead-hosts",
|
||||
}
|
||||
for _, path := range createPaths {
|
||||
t.Run("post "+path, func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+path, `{`, token), http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"malformed-ph.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
"forwardScheme": "http",
|
||||
}, token)
|
||||
if ph.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("proxy host: %d", ph.StatusCode)
|
||||
}
|
||||
var proxyHost store.ProxyHost
|
||||
decodeJSON(t, ph, &proxyHost)
|
||||
|
||||
al := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{
|
||||
"name": "malformed-acl",
|
||||
"clients": []map[string]string{{"address": "all", "directive": "allow"}},
|
||||
}, token)
|
||||
if al.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("acl: %d", al.StatusCode)
|
||||
}
|
||||
var accessList store.AccessList
|
||||
decodeJSON(t, al, &accessList)
|
||||
|
||||
strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": pickFreePort(t),
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 9,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
if strm.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("stream: %d", strm.StatusCode)
|
||||
}
|
||||
var stream store.Stream
|
||||
decodeJSON(t, strm, &stream)
|
||||
|
||||
redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"malformed-redir.example"},
|
||||
"forwardDomainName": "target.example",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if redir.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("redir: %d", redir.StatusCode)
|
||||
}
|
||||
var redirection store.RedirectionHost
|
||||
decodeJSON(t, redir, &redirection)
|
||||
|
||||
dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"malformed-dead.example"},
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if dead.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("dead: %d", dead.StatusCode)
|
||||
}
|
||||
var deadHost store.DeadHost
|
||||
decodeJSON(t, dead, &deadHost)
|
||||
|
||||
putCases := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"proxy-host", env.BaseURL + "/api/proxy-hosts/" + itoa(proxyHost.ID)},
|
||||
{"access-list", env.BaseURL + "/api/access-lists/" + itoa(accessList.ID)},
|
||||
{"stream", env.BaseURL + "/api/streams/" + itoa(stream.ID)},
|
||||
{"redirection", env.BaseURL + "/api/redirection-hosts/" + itoa(redirection.ID)},
|
||||
{"dead-host", env.BaseURL + "/api/dead-hosts/" + itoa(deadHost.ID)},
|
||||
}
|
||||
for _, tc := range putCases {
|
||||
t.Run("put "+tc.name, func(t *testing.T) {
|
||||
assertStatus(t, apiPutRaw(t, tc.url, `{`, token), http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_UpdateConflict409(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
t.Run("proxy-hosts", func(t *testing.T) {
|
||||
a := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"conflict-a.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
"forwardScheme": "http",
|
||||
}, token)
|
||||
b := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"conflict-b.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 2,
|
||||
"forwardScheme": "http",
|
||||
}, token)
|
||||
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
|
||||
}
|
||||
var hostB store.ProxyHost
|
||||
decodeJSON(t, b, &hostB)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(hostB.ID), map[string]any{
|
||||
"domainNames": []string{"conflict-a.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 2,
|
||||
"forwardScheme": "http",
|
||||
}, token), http.StatusConflict)
|
||||
})
|
||||
|
||||
t.Run("redirection-hosts", func(t *testing.T) {
|
||||
a := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"conflict-redir-a.example"},
|
||||
"forwardDomainName": "a.example",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
b := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"conflict-redir-b.example"},
|
||||
"forwardDomainName": "b.example",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
|
||||
}
|
||||
var hostB store.RedirectionHost
|
||||
decodeJSON(t, b, &hostB)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(hostB.ID), map[string]any{
|
||||
"domainNames": []string{"conflict-redir-a.example"},
|
||||
"forwardDomainName": "b.example",
|
||||
"enabled": true,
|
||||
}, token), http.StatusConflict)
|
||||
})
|
||||
|
||||
t.Run("dead-hosts", func(t *testing.T) {
|
||||
a := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"conflict-dead-a.example"},
|
||||
"enabled": true,
|
||||
}, token)
|
||||
b := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"conflict-dead-b.example"},
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
|
||||
}
|
||||
var hostB store.DeadHost
|
||||
decodeJSON(t, b, &hostB)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(hostB.ID), map[string]any{
|
||||
"domainNames": []string{"conflict-dead-a.example"},
|
||||
"enabled": true,
|
||||
}, token), http.StatusConflict)
|
||||
})
|
||||
|
||||
t.Run("streams", func(t *testing.T) {
|
||||
port := pickFreePort(t)
|
||||
a := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": port,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 9,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
otherPort := pickFreePort(t)
|
||||
b := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": otherPort,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 10,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
|
||||
}
|
||||
var streamB store.Stream
|
||||
decodeJSON(t, b, &streamB)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/"+itoa(streamB.ID), map[string]any{
|
||||
"incomingPort": port,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 10,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token), http.StatusConflict)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPI_ForbiddenEnableDisableBranches(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
adminTok := env.loginToken(t)
|
||||
userTok := env.userToken(t, 2)
|
||||
|
||||
ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"forbid-enable-ph.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
"forwardScheme": "http",
|
||||
"enabled": true,
|
||||
}, adminTok)
|
||||
var proxyHost store.ProxyHost
|
||||
decodeJSON(t, ph, &proxyHost)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID)+"/enable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": pickFreePort(t),
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 9,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, adminTok)
|
||||
var stream store.Stream
|
||||
decodeJSON(t, strm, &stream)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/streams/"+itoa(stream.ID)+"/disable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"forbid-disable-redir.example"},
|
||||
"forwardDomainName": "x.example",
|
||||
"enabled": true,
|
||||
}, adminTok)
|
||||
var redirection store.RedirectionHost
|
||||
decodeJSON(t, redir, &redirection)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID)+"/disable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"forbid-enable-dead.example"},
|
||||
"enabled": true,
|
||||
}, adminTok)
|
||||
var deadHost store.DeadHost
|
||||
decodeJSON(t, dead, &deadHost)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID)+"/enable", nil, userTok), http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestAPI_LoginLegacyPlainPassword(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
u, ok := env.Store.GetUserByEmail("admin@example.com")
|
||||
if !ok {
|
||||
t.Fatal("admin user missing")
|
||||
}
|
||||
u.Password = "legacyplain"
|
||||
if err := env.Store.UpdateUser(u); err != nil {
|
||||
t.Fatalf("update user: %v", err)
|
||||
}
|
||||
|
||||
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "legacyplain"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("legacy login: %d %s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
var loginOut struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decodeJSON(t, resp, &loginOut)
|
||||
if loginOut.Token == "" {
|
||||
t.Fatal("empty token for legacy login")
|
||||
}
|
||||
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
|
||||
"currentPassword": "legacyplain",
|
||||
"newPassword": "newsecure123",
|
||||
}, loginOut.Token), http.StatusOK)
|
||||
}
|
||||
|
||||
func TestAPI_PasswordChangeEmptyNew(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
|
||||
"currentPassword": "password",
|
||||
"newPassword": "",
|
||||
}, token), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestAPI_ListEndpointsOK(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
lists := []string{
|
||||
"/api/access-lists",
|
||||
"/api/streams",
|
||||
"/api/certificates",
|
||||
"/api/redirection-hosts",
|
||||
"/api/dead-hosts",
|
||||
"/api/settings",
|
||||
}
|
||||
for _, path := range lists {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
assertStatus(t, apiGet(t, env.BaseURL+path, token), http.StatusOK)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_DeleteNotFoundBranches(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
cases := []string{
|
||||
"/api/redirection-hosts/9999",
|
||||
"/api/dead-hosts/9999",
|
||||
"/api/streams/9999",
|
||||
}
|
||||
for _, path := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+path, token), http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_PutNotFoundBranches(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
body map[string]any
|
||||
}{
|
||||
{"/api/streams/9999", map[string]any{
|
||||
"incomingPort": 1, "forwardingHost": "127.0.0.1", "forwardingPort": 1, "tcpForwarding": true,
|
||||
}},
|
||||
{"/api/redirection-hosts/9999", map[string]any{"domainNames": []string{"x.example"}}},
|
||||
{"/api/dead-hosts/9999", map[string]any{"domainNames": []string{"x.example"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
assertStatus(t, apiPut(t, env.BaseURL+tc.path, tc.body, token), http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
func assertStatus(t *testing.T, resp *http.Response, want int) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != want {
|
||||
t.Errorf("status: got %d want %d body=%s", resp.StatusCode, want, readBody(resp))
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func apiPostRaw(t *testing.T, url, rawBody, token string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte(rawBody)))
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAPI_ErrorPaths(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
t.Run("auth", func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/login", `{`, ""), http.StatusBadRequest)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"}, ""), http.StatusUnauthorized)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "not-a-jwt"), http.StatusUnauthorized)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "Bearer"), http.StatusUnauthorized)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "Basic dXNlcjpwYXNz"), http.StatusUnauthorized)
|
||||
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/users/me/password", `not-json`, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
|
||||
"currentPassword": "password",
|
||||
"newPassword": "password",
|
||||
}, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
|
||||
"currentPassword": "wrong",
|
||||
"newPassword": "newpass123",
|
||||
}, token), http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("proxy-hosts", func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/proxy-hosts", `{`, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts/bad-id", token), http.StatusBadRequest)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts/9999", token), http.StatusNotFound)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/proxy-hosts/9999", map[string]any{
|
||||
"domainNames": []string{"x.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
}, token), http.StatusNotFound)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/proxy-hosts/9999", token), http.StatusNotFound)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/9999/enable", nil, token), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("access-lists", func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/access-lists", `{`, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/access-lists/nope", token), http.StatusBadRequest)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/access-lists/9999", token), http.StatusNotFound)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/access-lists/9999", map[string]any{"name": "x"}, token), http.StatusNotFound)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/access-lists/9999", token), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("streams", func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/streams", `{`, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/bad", map[string]any{}, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/streams/9999", token), http.StatusNotFound)
|
||||
|
||||
first := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": 29100,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 9,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
if first.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("stream create: %d %s", first.StatusCode, readBody(first))
|
||||
}
|
||||
dup := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": 29100,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 10,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
assertStatus(t, dup, http.StatusConflict)
|
||||
})
|
||||
|
||||
t.Run("certificates", func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/certificates", `{`, token), http.StatusBadRequest)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/certificates/9999", token), http.StatusNotFound)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/9999/renew", nil, token), http.StatusNotFound)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/bad/renew", nil, token), http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("redirection-hosts", func(t *testing.T) {
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/9999", map[string]any{
|
||||
"domainNames": []string{"x.example"},
|
||||
}, token), http.StatusNotFound)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/9999/disable", nil, token), http.StatusNotFound)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/bad/enable", nil, token), http.StatusBadRequest)
|
||||
|
||||
first := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"dup-redir.example"},
|
||||
"forwardDomainName": "target.example",
|
||||
"forwardScheme": "https",
|
||||
"preservePath": true,
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if first.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("redir create: %d %s", first.StatusCode, readBody(first))
|
||||
}
|
||||
dup := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"dup-redir.example"},
|
||||
"forwardDomainName": "other.example",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
assertStatus(t, dup, http.StatusConflict)
|
||||
})
|
||||
|
||||
t.Run("dead-hosts", func(t *testing.T) {
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/dead-hosts/9999", token), http.StatusNotFound)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/bad/enable", nil, token), http.StatusBadRequest)
|
||||
|
||||
first := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"dup-dead.example"},
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if first.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("dead create: %d %s", first.StatusCode, readBody(first))
|
||||
}
|
||||
dup := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"dup-dead.example"},
|
||||
"enabled": true,
|
||||
}, token)
|
||||
assertStatus(t, dup, http.StatusConflict)
|
||||
})
|
||||
|
||||
t.Run("settings", func(t *testing.T) {
|
||||
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/settings", `{`, token), http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPI_ForbiddenNonOwner(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
adminTok := env.loginToken(t)
|
||||
userTok := env.userToken(t, 2)
|
||||
|
||||
al := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{
|
||||
"name": "owned-by-admin",
|
||||
"clients": []map[string]string{{"address": "all", "directive": "allow"}},
|
||||
}, adminTok)
|
||||
if al.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("acl create: %d", al.StatusCode)
|
||||
}
|
||||
var created store.AccessList
|
||||
decodeJSON(t, al, &created)
|
||||
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/access-lists/"+itoa(created.ID), map[string]any{
|
||||
"name": "hijack",
|
||||
}, userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/access-lists/"+itoa(created.ID), userTok), http.StatusForbidden)
|
||||
|
||||
ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"forbidden-ph.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
"forwardScheme": "http",
|
||||
}, adminTok)
|
||||
if ph.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("proxy host: %d", ph.StatusCode)
|
||||
}
|
||||
var proxyHost store.ProxyHost
|
||||
decodeJSON(t, ph, &proxyHost)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID), userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID)+"/disable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": pickFreePort(t),
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 9,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, adminTok)
|
||||
if strm.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("stream: %d %s", strm.StatusCode, readBody(strm))
|
||||
}
|
||||
var stream store.Stream
|
||||
decodeJSON(t, strm, &stream)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/"+itoa(stream.ID), map[string]any{
|
||||
"incomingPort": stream.IncomingPort,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 10,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/streams/"+itoa(stream.ID), userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/streams/"+itoa(stream.ID)+"/enable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"forbidden-redir.example"},
|
||||
"forwardDomainName": "elsewhere.example",
|
||||
"enabled": true,
|
||||
}, adminTok)
|
||||
if redir.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("redir: %d", redir.StatusCode)
|
||||
}
|
||||
var redirection store.RedirectionHost
|
||||
decodeJSON(t, redir, &redirection)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID), map[string]any{
|
||||
"domainNames": []string{"hijack-redir.example"},
|
||||
}, userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID), userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID)+"/enable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"forbidden-dead.example"},
|
||||
"enabled": true,
|
||||
}, adminTok)
|
||||
if dead.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("dead: %d", dead.StatusCode)
|
||||
}
|
||||
var deadHost store.DeadHost
|
||||
decodeJSON(t, dead, &deadHost)
|
||||
assertStatus(t, apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID), map[string]any{
|
||||
"domainNames": []string{"hijack-dead.example"},
|
||||
}, userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID), userTok), http.StatusForbidden)
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID)+"/disable", nil, userTok), http.StatusForbidden)
|
||||
|
||||
cert := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "other",
|
||||
"domainNames": []string{"forbidden-cert.example"},
|
||||
}, adminTok)
|
||||
if cert.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("cert: %d", cert.StatusCode)
|
||||
}
|
||||
var certificate store.Certificate
|
||||
decodeJSON(t, cert, &certificate)
|
||||
assertStatus(t, apiDelete(t, env.BaseURL+"/api/certificates/"+itoa(certificate.ID), userTok), http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestAPI_CertificateFailures(t *testing.T) {
|
||||
t.Run("other provider create succeeds without issuance", func(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
resp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "other",
|
||||
"domainNames": []string{"custom.example"},
|
||||
}, token)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
})
|
||||
|
||||
t.Run("LE empty domains rolls back in production", func(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "")
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
resp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "letsencrypt",
|
||||
"domainNames": []string{},
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("empty domains: %d %s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
body := readBody(resp)
|
||||
if !strings.Contains(body, "letsencrypt issuance failed") {
|
||||
t.Errorf("body: %q", body)
|
||||
}
|
||||
if certs := env.Store.GetCertificates(); len(certs) != 0 {
|
||||
t.Errorf("failed issue should roll back cert, got %d", len(certs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renew non-LE fails", func(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "other",
|
||||
"domainNames": []string{"renew-fail.example"},
|
||||
}, token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
var cert store.Certificate
|
||||
decodeJSON(t, create, &cert)
|
||||
|
||||
renew := apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, token)
|
||||
body := readBody(renew)
|
||||
if renew.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("renew: %d %s", renew.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "renewal failed") {
|
||||
t.Errorf("expected renewal failed message, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renew forbidden for non-owner", func(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
adminTok := env.loginToken(t)
|
||||
userTok := env.userToken(t, 2)
|
||||
|
||||
create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "other",
|
||||
"domainNames": []string{"owned.example"},
|
||||
}, adminTok)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d", create.StatusCode)
|
||||
}
|
||||
var cert store.Certificate
|
||||
decodeJSON(t, create, &cert)
|
||||
|
||||
assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, userTok), http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPI_UsersList(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
resp := apiGet(t, env.BaseURL+"/api/users", token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("users: %d", resp.StatusCode)
|
||||
}
|
||||
var users []map[string]any
|
||||
decodeJSON(t, resp, &users)
|
||||
if len(users) != 1 || users[0]["email"] != "admin@example.com" {
|
||||
t.Errorf("users: %v", users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
func (e *apiTestEnv) loginToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": "password"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decodeJSON(t, resp, &out)
|
||||
if out.Token == "" {
|
||||
t.Fatal("empty login token")
|
||||
}
|
||||
return out.Token
|
||||
}
|
||||
|
||||
func (e *apiTestEnv) userToken(t *testing.T, userID int) string {
|
||||
t.Helper()
|
||||
tok, err := e.JWT.GenerateToken(userID, "user@example.com", "User", []string{"user"})
|
||||
if err != nil {
|
||||
t.Fatalf("user token: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func apiRequest(t *testing.T, method, url, token string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, r)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func apiGet(t *testing.T, url, token string) *http.Response {
|
||||
return apiRequest(t, http.MethodGet, url, token, nil)
|
||||
}
|
||||
|
||||
func apiPost(t *testing.T, url string, body any, token string) *http.Response {
|
||||
return apiRequest(t, http.MethodPost, url, token, body)
|
||||
}
|
||||
|
||||
func apiPut(t *testing.T, url string, body any, token string) *http.Response {
|
||||
return apiRequest(t, http.MethodPut, url, token, body)
|
||||
}
|
||||
|
||||
func apiDelete(t *testing.T, url, token string) *http.Response {
|
||||
return apiRequest(t, http.MethodDelete, url, token, nil)
|
||||
}
|
||||
|
||||
func readBody(resp *http.Response) string {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, resp *http.Response, dst any) {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, readBody(resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_PublicAndAuth(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
|
||||
// health (no token)
|
||||
resp := apiGet(t, env.BaseURL+"/api/", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("health: %d", resp.StatusCode)
|
||||
}
|
||||
var health map[string]any
|
||||
decodeJSON(t, resp, &health)
|
||||
if health["status"] != "OK" {
|
||||
t.Errorf("health status: %v", health["status"])
|
||||
}
|
||||
|
||||
// version (no RequireAuth in handler)
|
||||
resp = apiGet(t, env.BaseURL+"/api/version", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("version: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// protected list without token -> 401
|
||||
resp = apiGet(t, env.BaseURL+"/api/proxy-hosts", "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("proxy-hosts no auth: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
token := env.loginToken(t)
|
||||
|
||||
// me
|
||||
resp = apiGet(t, env.BaseURL+"/api/users/me", token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("users/me: %d", resp.StatusCode)
|
||||
}
|
||||
var me map[string]any
|
||||
decodeJSON(t, resp, &me)
|
||||
if me["email"] != "admin@example.com" {
|
||||
t.Errorf("me email: %v", me["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_ProxyHostCRUD(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"api-proxy.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 8080,
|
||||
"forwardScheme": "http",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
var ph store.ProxyHost
|
||||
decodeJSON(t, create, &ph)
|
||||
if ph.ID == 0 || ph.OwnerUserID != 1 {
|
||||
t.Fatalf("created host: %+v", ph)
|
||||
}
|
||||
|
||||
list := apiGet(t, env.BaseURL+"/api/proxy-hosts", token)
|
||||
if list.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list: %d", list.StatusCode)
|
||||
}
|
||||
var hosts []store.ProxyHost
|
||||
decodeJSON(t, list, &hosts)
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("list len: %d", len(hosts))
|
||||
}
|
||||
|
||||
get := apiGet(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), token)
|
||||
if get.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get: %d", get.StatusCode)
|
||||
}
|
||||
|
||||
up := apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), map[string]any{
|
||||
"domainNames": []string{"api-proxy-updated.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 9090,
|
||||
"forwardScheme": "http",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if up.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update: %d %s", up.StatusCode, readBody(up))
|
||||
}
|
||||
|
||||
dis := apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID)+"/disable", nil, token)
|
||||
if dis.StatusCode != http.StatusOK {
|
||||
t.Fatalf("disable: %d", dis.StatusCode)
|
||||
}
|
||||
en := apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID)+"/enable", nil, token)
|
||||
if en.StatusCode != http.StatusOK {
|
||||
t.Fatalf("enable: %d", en.StatusCode)
|
||||
}
|
||||
|
||||
// duplicate domain -> 409
|
||||
dup := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"api-proxy-updated.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
"forwardScheme": "http",
|
||||
}, token)
|
||||
if dup.StatusCode != http.StatusConflict {
|
||||
t.Errorf("duplicate domain: %d %s", dup.StatusCode, readBody(dup))
|
||||
}
|
||||
|
||||
// non-owner forbidden
|
||||
userTok := env.userToken(t, 2)
|
||||
forbid := apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), map[string]any{
|
||||
"domainNames": []string{"hijack.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 1,
|
||||
}, userTok)
|
||||
if forbid.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("non-owner update: %d", forbid.StatusCode)
|
||||
}
|
||||
|
||||
del := apiDelete(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), token)
|
||||
if del.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d %s", del.StatusCode, readBody(del))
|
||||
}
|
||||
|
||||
logs := apiGet(t, env.BaseURL+"/api/audit-logs", token)
|
||||
if logs.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit: %d", logs.StatusCode)
|
||||
}
|
||||
var audit []store.AuditLog
|
||||
decodeJSON(t, logs, &audit)
|
||||
if len(audit) < 3 {
|
||||
t.Errorf("expected audit entries, got %d", len(audit))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_AccessListCRUD(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
create := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{
|
||||
"name": "api-acl",
|
||||
"clients": []map[string]string{
|
||||
{"address": "all", "directive": "allow"},
|
||||
},
|
||||
"items": []map[string]string{
|
||||
{"username": "u", "password": "p"},
|
||||
},
|
||||
}, token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
var al store.AccessList
|
||||
decodeJSON(t, create, &al)
|
||||
|
||||
get := apiGet(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), token)
|
||||
if get.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get: %d", get.StatusCode)
|
||||
}
|
||||
|
||||
up := apiPut(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), map[string]any{
|
||||
"name": "api-acl-updated",
|
||||
"passAuth": true,
|
||||
"clients": []map[string]string{{"address": "all", "directive": "allow"}},
|
||||
"items": []map[string]string{{"username": "u2", "password": "p2"}},
|
||||
}, token)
|
||||
if up.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update: %d %s", up.StatusCode, readBody(up))
|
||||
}
|
||||
|
||||
del := apiDelete(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), token)
|
||||
if del.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", del.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_RedirectionAndDeadHosts(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
rh := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
|
||||
"domainNames": []string{"api-redir.example"},
|
||||
"forwardHttpCode": 302,
|
||||
"forwardScheme": "https",
|
||||
"forwardDomainName": "target.example",
|
||||
"preservePath": true,
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if rh.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("redir create: %d %s", rh.StatusCode, readBody(rh))
|
||||
}
|
||||
var redir store.RedirectionHost
|
||||
decodeJSON(t, rh, &redir)
|
||||
|
||||
up := apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID), map[string]any{
|
||||
"domainNames": []string{"api-redir.example"},
|
||||
"forwardHttpCode": 301,
|
||||
"forwardScheme": "https",
|
||||
"forwardDomainName": "other.example",
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if up.StatusCode != http.StatusOK {
|
||||
t.Fatalf("redir update: %d", up.StatusCode)
|
||||
}
|
||||
if apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID)+"/disable", nil, token).StatusCode != http.StatusOK {
|
||||
t.Error("redir disable failed")
|
||||
}
|
||||
if apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID)+"/enable", nil, token).StatusCode != http.StatusOK {
|
||||
t.Error("redir enable failed")
|
||||
}
|
||||
|
||||
dh := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
|
||||
"domainNames": []string{"api-dead.example"},
|
||||
"enabled": true,
|
||||
"meta": map[string]any{"html": "<b>dead</b>"},
|
||||
}, token)
|
||||
if dh.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("dead create: %d %s", dh.StatusCode, readBody(dh))
|
||||
}
|
||||
var dead store.DeadHost
|
||||
decodeJSON(t, dh, &dead)
|
||||
|
||||
if apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(dead.ID), map[string]any{
|
||||
"domainNames": []string{"api-dead.example"},
|
||||
"enabled": true,
|
||||
}, token).StatusCode != http.StatusOK {
|
||||
t.Error("dead update failed")
|
||||
}
|
||||
if apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(dead.ID)+"/disable", nil, token).StatusCode != http.StatusOK {
|
||||
t.Error("dead disable failed")
|
||||
}
|
||||
|
||||
list := apiGet(t, env.BaseURL+"/api/redirection-hosts", token)
|
||||
if list.StatusCode != http.StatusOK {
|
||||
t.Fatalf("redir list: %d", list.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_StreamCRUD(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
create := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
|
||||
"incomingPort": 29001,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 9,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
var strm store.Stream
|
||||
decodeJSON(t, create, &strm)
|
||||
|
||||
up := apiPut(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), map[string]any{
|
||||
"incomingPort": 29001,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 10,
|
||||
"tcpForwarding": true,
|
||||
"enabled": false,
|
||||
}, token)
|
||||
if up.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update: %d %s", up.StatusCode, readBody(up))
|
||||
}
|
||||
|
||||
if apiPost(t, env.BaseURL+"/api/streams/"+itoa(strm.ID)+"/enable", nil, token).StatusCode != http.StatusOK {
|
||||
t.Error("enable failed")
|
||||
}
|
||||
|
||||
enabledUp := apiPut(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), map[string]any{
|
||||
"incomingPort": 29001,
|
||||
"forwardingHost": "127.0.0.1",
|
||||
"forwardingPort": 11,
|
||||
"tcpForwarding": true,
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if enabledUp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update enabled same port: %d %s", enabledUp.StatusCode, readBody(enabledUp))
|
||||
}
|
||||
var enabledResp struct {
|
||||
store.Stream
|
||||
Listening bool `json:"listening"`
|
||||
ListenError string `json:"listenError"`
|
||||
}
|
||||
decodeJSON(t, enabledUp, &enabledResp)
|
||||
if !enabledResp.Listening {
|
||||
t.Fatalf("expected enabled stream listening after edit, got listenError=%q", enabledResp.ListenError)
|
||||
}
|
||||
|
||||
if apiPost(t, env.BaseURL+"/api/streams/"+itoa(strm.ID)+"/disable", nil, token).StatusCode != http.StatusOK {
|
||||
t.Error("disable failed")
|
||||
}
|
||||
|
||||
del := apiDelete(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), token)
|
||||
if del.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", del.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_CertificateCRUD(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "letsencrypt",
|
||||
"domainNames": []string{"api-cert.example"},
|
||||
}, token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
var cert store.Certificate
|
||||
decodeJSON(t, create, &cert)
|
||||
if cert.ID == 0 {
|
||||
t.Fatal("cert id missing")
|
||||
}
|
||||
|
||||
list := apiGet(t, env.BaseURL+"/api/certificates", token)
|
||||
if list.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list: %d", list.StatusCode)
|
||||
}
|
||||
|
||||
renew := apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, token)
|
||||
if renew.StatusCode != http.StatusOK {
|
||||
t.Fatalf("renew: %d %s", renew.StatusCode, readBody(renew))
|
||||
}
|
||||
|
||||
del := apiDelete(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID), token)
|
||||
if del.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", del.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_SettingsDashboard(t *testing.T) {
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
set := apiPost(t, env.BaseURL+"/api/settings", map[string]any{
|
||||
"default_site": "404",
|
||||
}, token)
|
||||
if set.StatusCode != http.StatusOK {
|
||||
t.Fatalf("settings post: %d %s", set.StatusCode, readBody(set))
|
||||
}
|
||||
var settings map[string]any
|
||||
decodeJSON(t, set, &settings)
|
||||
if settings["default_site"] != "404" {
|
||||
t.Errorf("default_site: %v", settings["default_site"])
|
||||
}
|
||||
|
||||
dash := apiGet(t, env.BaseURL+"/api/dashboard", token)
|
||||
if dash.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dashboard: %d", dash.StatusCode)
|
||||
}
|
||||
var counts map[string]any
|
||||
decodeJSON(t, dash, &counts)
|
||||
if _, ok := counts["proxy_hosts"]; !ok {
|
||||
t.Error("dashboard missing proxy_hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_WriteStoreError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeStoreError(w, store.ErrDuplicateStreamPort)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("conflict: %d", w.Code)
|
||||
}
|
||||
w2 := httptest.NewRecorder()
|
||||
writeStoreError(w2, io.ErrUnexpectedEOF)
|
||||
if w2.Code != http.StatusInternalServerError {
|
||||
t.Errorf("generic: %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"helix-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// serveHTTPS listens with TLS and serves the proxy engine handler (SNI via eng.TLSConfig).
|
||||
// Shuts down when ctx is cancelled.
|
||||
func serveHTTPS(ctx context.Context, eng *proxy.Engine, addr string) error {
|
||||
ln, err := tls.Listen("tcp", addr, eng.TLSConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: eng.Handler(),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
_ = ln.Close()
|
||||
}()
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyUmaskFromEnv sets the process umask from UMASK env (octal) or defaults to 022.
|
||||
func applyUmaskFromEnv() {
|
||||
if u := os.Getenv("UMASK"); u != "" {
|
||||
if mode, err := strconv.ParseInt(u, 8, 32); err == nil {
|
||||
syscall.Umask(int(mode))
|
||||
return
|
||||
}
|
||||
slog.Warn("invalid UMASK env (ignored)", "val", u)
|
||||
}
|
||||
syscall.Umask(0o022)
|
||||
}
|
||||
|
||||
// adminListenAddr resolves the admin API/UI listen address from env.
|
||||
func adminListenAddr() string {
|
||||
port := "8081"
|
||||
if p := os.Getenv("ADMIN_PORT"); p != "" {
|
||||
port = p
|
||||
}
|
||||
if os.Getenv("DISABLE_IPV6") == "1" {
|
||||
return "0.0.0.0:" + port
|
||||
}
|
||||
return net.JoinHostPort("", port)
|
||||
}
|
||||
|
||||
// proxyHTTPSListenAddr resolves the TLS proxy listen address from env.
|
||||
func proxyHTTPSListenAddr() string {
|
||||
port := "18443"
|
||||
if p := os.Getenv("PROXY_HTTPS_PORT"); p != "" {
|
||||
port = p
|
||||
}
|
||||
if os.Getenv("DISABLE_IPV6") == "1" {
|
||||
return "0.0.0.0:" + port
|
||||
}
|
||||
return net.JoinHostPort("", port)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminListenAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "defaults ipv6",
|
||||
env: map[string]string{},
|
||||
expect: ":8081",
|
||||
},
|
||||
{
|
||||
name: "custom port",
|
||||
env: map[string]string{"ADMIN_PORT": "9090"},
|
||||
expect: ":9090",
|
||||
},
|
||||
{
|
||||
name: "disable ipv6",
|
||||
env: map[string]string{
|
||||
"DISABLE_IPV6": "1",
|
||||
},
|
||||
expect: "0.0.0.0:8081",
|
||||
},
|
||||
{
|
||||
name: "custom port ipv4 only",
|
||||
env: map[string]string{
|
||||
"ADMIN_PORT": "3000",
|
||||
"DISABLE_IPV6": "1",
|
||||
},
|
||||
expect: "0.0.0.0:3000",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("ADMIN_PORT", "")
|
||||
t.Setenv("DISABLE_IPV6", "")
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if got := adminListenAddr(); got != tc.expect {
|
||||
t.Errorf("adminListenAddr() = %q want %q", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHTTPSListenAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "defaults",
|
||||
env: map[string]string{},
|
||||
expect: ":18443",
|
||||
},
|
||||
{
|
||||
name: "custom port",
|
||||
env: map[string]string{"PROXY_HTTPS_PORT": "8443"},
|
||||
expect: ":8443",
|
||||
},
|
||||
{
|
||||
name: "ipv4 only",
|
||||
env: map[string]string{
|
||||
"DISABLE_IPV6": "1",
|
||||
"PROXY_HTTPS_PORT": "9443",
|
||||
},
|
||||
expect: "0.0.0.0:9443",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("PROXY_HTTPS_PORT", "")
|
||||
t.Setenv("DISABLE_IPV6", "")
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if got := proxyHTTPSListenAddr(); got != tc.expect {
|
||||
t.Errorf("proxyHTTPSListenAddr() = %q want %q", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUmaskFromEnv(t *testing.T) {
|
||||
t.Run("invalid ignored", func(t *testing.T) {
|
||||
t.Setenv("UMASK", "not-octal")
|
||||
applyUmaskFromEnv() // should not panic
|
||||
})
|
||||
t.Run("valid octal", func(t *testing.T) {
|
||||
t.Setenv("UMASK", "0077")
|
||||
applyUmaskFromEnv()
|
||||
})
|
||||
t.Run("default when unset", func(t *testing.T) {
|
||||
t.Setenv("UMASK", "")
|
||||
applyUmaskFromEnv()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx); err != nil {
|
||||
slog.Error("run failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- permission helpers for granular object ownership + role checks (admin can do all) ---
|
||||
|
||||
func isAdmin(c *auth.Claims) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range c.Roles {
|
||||
if r == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeStoreError(w http.ResponseWriter, err error) {
|
||||
if store.IsConflictError(err) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func canManage(c *auth.Claims, ownerUserID int) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if isAdmin(c) {
|
||||
return true
|
||||
}
|
||||
return c.UserID == ownerUserID
|
||||
}
|
||||
|
||||
// applyPUIDPGID chowns the data tree (and key subdirs) if PUID/PGID envs are set
|
||||
// and the process is running as root (0). This replicates the spirit of original
|
||||
// entrypoint scripts for Docker non-root runs. After chown, if root, drops privs
|
||||
// via Setgid+Setuid so process runs as target user (enables low ports etc only if
|
||||
// needed before drop; matches summary/README "full" PUID claims).
|
||||
// umask handled early in main() to affect creates.
|
||||
// To support low ports (80/443) + PUID (which requires root at Listen time), set PUID_NO_DROP=1
|
||||
// (chown still done as root, but no drop -- process stays root; or use high ports/caps/entrypoint drop).
|
||||
func applyPUIDPGID() error {
|
||||
puidStr := os.Getenv("PUID")
|
||||
pgidStr := os.Getenv("PGID")
|
||||
if puidStr == "" || pgidStr == "" {
|
||||
return nil
|
||||
}
|
||||
uid, err := strconv.Atoi(puidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gid, err := strconv.Atoi(pgidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Getuid() != 0 {
|
||||
return nil // not root, skip
|
||||
}
|
||||
dataDir := config.DataDir()
|
||||
dirs := []string{
|
||||
dataDir,
|
||||
config.Resolve("certs"),
|
||||
config.Resolve("logs"),
|
||||
config.Resolve("letsencrypt-acme-challenge"),
|
||||
config.Resolve("www"),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.Chown(d, uid, gid); err != nil {
|
||||
slog.Warn("chown skipped", "dir", d, "err", err)
|
||||
// continue for other dirs
|
||||
}
|
||||
// recursive for subfiles/dirs (use WalkDir for modern Go)
|
||||
filepath.WalkDir(d, func(path string, de os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
// ignore some
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
slog.Info("applied PUID/PGID chown", "uid", uid, "gid", gid, "data", dataDir)
|
||||
|
||||
if os.Getenv("PUID_NO_DROP") != "" {
|
||||
slog.Info("PUID_NO_DROP set; skipping privilege drop after chown (remains root for low-port binds if needed)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// drop privileges (setgid before setuid; only while still root)
|
||||
if err := syscall.Setgid(gid); err != nil {
|
||||
slog.Warn("Setgid after chown failed (still root)", "gid", gid, "err", err)
|
||||
} else if err := syscall.Setuid(uid); err != nil {
|
||||
slog.Warn("Setuid after chown failed", "uid", uid, "err", err)
|
||||
} else {
|
||||
slog.Info("dropped to PUID/PGID (non-root)", "uid", uid, "gid", gid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBcryptPrefix detects bcrypt-hashed passwords for strict compare (no plain fallback).
|
||||
// Supports common variants; used in login to prevent hash-str itself being usable as pw.
|
||||
func isBcryptPrefix(pw string) bool {
|
||||
return len(pw) > 0 && (strings.HasPrefix(pw, "$2a$") || strings.HasPrefix(pw, "$2b$") || strings.HasPrefix(pw, "$2y$"))
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
)
|
||||
|
||||
func TestIsBcryptPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"plain", false},
|
||||
{"$2a$10$abcdefghijklmnopqrstuv", true},
|
||||
{"$2b$12$hash", true},
|
||||
{"$2y$05$hash", true},
|
||||
{"$2x$10$hash", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isBcryptPrefix(tc.in); got != tc.want {
|
||||
t.Errorf("isBcryptPrefix(%q) = %v want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAdminCanManage(t *testing.T) {
|
||||
adminClaims := &auth.Claims{UserID: 1, Roles: []string{"admin"}}
|
||||
userClaims := &auth.Claims{UserID: 2, Roles: []string{"user"}}
|
||||
nilClaims := (*auth.Claims)(nil)
|
||||
|
||||
if !isAdmin(adminClaims) {
|
||||
t.Error("admin should be admin")
|
||||
}
|
||||
if isAdmin(userClaims) {
|
||||
t.Error("user not admin")
|
||||
}
|
||||
if isAdmin(nilClaims) {
|
||||
t.Error("nil not admin")
|
||||
}
|
||||
|
||||
if !canManage(adminClaims, 99) {
|
||||
t.Error("admin can manage any")
|
||||
}
|
||||
if !canManage(userClaims, 2) {
|
||||
t.Error("owner can manage self")
|
||||
}
|
||||
if canManage(userClaims, 3) {
|
||||
t.Error("user cannot manage other")
|
||||
}
|
||||
if canManage(nilClaims, 1) {
|
||||
t.Error("nil cannot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
env := newAPITestServer(t)
|
||||
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("default login failed: %d", resp.StatusCode)
|
||||
}
|
||||
var lr struct {
|
||||
Token string `json:"token"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&lr)
|
||||
if lr.Token == "" || lr.MustChangePassword {
|
||||
t.Errorf("dev mode default login should not require password change: %+v", lr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleAdminLogin(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "")
|
||||
env := newAPITestServer(t)
|
||||
st := env.Store
|
||||
|
||||
// initial default "password" login returns mustChangePassword
|
||||
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("default login failed: %d", resp.StatusCode)
|
||||
}
|
||||
var lr struct {
|
||||
Token string `json:"token"`
|
||||
User map[string]any `json:"user"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&lr)
|
||||
if lr.Token == "" || lr.User["id"] != float64(1) || !lr.MustChangePassword {
|
||||
t.Errorf("default login resp bad: %+v", lr)
|
||||
}
|
||||
|
||||
// wrong default fails
|
||||
respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"})
|
||||
if respBad.StatusCode != 401 {
|
||||
t.Errorf("wrong default should 401: %d", respBad.StatusCode)
|
||||
}
|
||||
|
||||
// use the returned token to change password (initial case)
|
||||
token := lr.Token
|
||||
authH := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token) }
|
||||
chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "password", "newPassword": "newpass123"}, authH)
|
||||
if chResp.StatusCode != 200 {
|
||||
t.Fatalf("initial pw change failed: %d", chResp.StatusCode)
|
||||
}
|
||||
|
||||
// after change, login with new pw succeeds, no mustChange
|
||||
resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"})
|
||||
if resp2.StatusCode != 200 {
|
||||
t.Fatalf("new pw login failed: %d", resp2.StatusCode)
|
||||
}
|
||||
var lr2 struct {
|
||||
Token string `json:"token"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
}
|
||||
json.NewDecoder(resp2.Body).Decode(&lr2)
|
||||
if lr2.MustChangePassword {
|
||||
t.Error("after change, mustChange should be false")
|
||||
}
|
||||
|
||||
// confirm stored as bcrypt in the test store
|
||||
uu, _ := st.GetUserByEmail("admin@example.com")
|
||||
if uu.Password == "" || !isBcryptPrefix(uu.Password) {
|
||||
t.Error("password not stored as bcrypt after change")
|
||||
}
|
||||
|
||||
// changing again requires correct current
|
||||
token2 := lr2.Token
|
||||
authH2 := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token2) }
|
||||
badCur := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "wrong", "newPassword": "another"}, authH2)
|
||||
if badCur.StatusCode != 401 {
|
||||
t.Errorf("bad current on change should 401: %d", badCur.StatusCode)
|
||||
}
|
||||
goodCh := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "newpass123", "newPassword": "finalpass"}, authH2)
|
||||
if goodCh.StatusCode != 200 {
|
||||
t.Errorf("subsequent change failed: %d", goodCh.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url string, body any) *http.Response {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return doReqClient(req)
|
||||
}
|
||||
|
||||
func postJSONAuth(t *testing.T, url string, body any, authSetter func(*http.Request)) *http.Response {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
authSetter(req)
|
||||
return doReqClient(req)
|
||||
}
|
||||
|
||||
func doReqAuth(method, url string, body any, authSetter func(*http.Request)) *http.Response {
|
||||
var br io.Reader
|
||||
if body != nil {
|
||||
bb, _ := json.Marshal(body)
|
||||
br = bytes.NewReader(bb)
|
||||
}
|
||||
req, _ := http.NewRequest(method, url, br)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
authSetter(req)
|
||||
return doReqClient(req)
|
||||
}
|
||||
|
||||
func doReqClient(req *http.Request) *http.Response {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
"helix-proxy/internal/certificate"
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/proxy"
|
||||
"helix-proxy/internal/store"
|
||||
"helix-proxy/ui"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// newAdminRouter builds the admin UI + API chi router (no listeners started).
|
||||
func newAdminRouter(st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
mountAPI(r, st, eng, cm, jwtMgr)
|
||||
mountSPA(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// mountSPA serves the embedded Svelte SPA or a build placeholder.
|
||||
func mountSPA(r chi.Router) {
|
||||
spaSub, err := fs.Sub(ui.Dist, "dist")
|
||||
if err != nil {
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<!doctype html>
|
||||
<html><head><title>Helix Proxy</title></head>
|
||||
<body style="font-family:system-ui;padding:2rem;max-width:720px;margin:0 auto">
|
||||
<h1>Helix Proxy</h1>
|
||||
<p>UI not built yet — run <code>make ui-build</code> then <code>make</code> (or <code>go build</code>).</p>
|
||||
<p>Health: <a href="/api">/api</a> (cwd-relative data at <code>`+config.DataDir()+`</code>)</p>
|
||||
<p>All paths (data, www/html, certs, logs...) default to ./data/... and ./data/www/ relative to the process CWD. Override with DATA_DIR, WWW_DIR etc.</p>
|
||||
</body></html>`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(spaSub))
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if p != "" && p[0] == '/' {
|
||||
p = p[1:]
|
||||
}
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, statErr := fs.Stat(spaSub, p); errors.Is(statErr, fs.ErrNotExist) {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminRouter_SPAFallback(t *testing.T) {
|
||||
env := newAdminTestServer(t)
|
||||
|
||||
resp := apiGet(t, env.BaseURL+"/api/", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("api health: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = apiGet(t, env.BaseURL+"/", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spa root: %d", resp.StatusCode)
|
||||
}
|
||||
body := readBody(resp)
|
||||
if !strings.Contains(body, "Helix Proxy") {
|
||||
t.Errorf("spa body missing title: %q", body[:min(120, len(body))])
|
||||
}
|
||||
|
||||
resp = apiGet(t, env.BaseURL+"/some/spa/route", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spa route: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPUIDPGID(t *testing.T) {
|
||||
t.Run("no env", func(t *testing.T) {
|
||||
t.Setenv("PUID", "")
|
||||
t.Setenv("PGID", "")
|
||||
if err := applyPUIDPGID(); err != nil {
|
||||
t.Errorf("empty env: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid PUID", func(t *testing.T) {
|
||||
t.Setenv("PUID", "not-a-number")
|
||||
t.Setenv("PGID", "1000")
|
||||
if err := applyPUIDPGID(); err == nil {
|
||||
t.Error("expected error for invalid PUID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid PGID", func(t *testing.T) {
|
||||
t.Setenv("PUID", "1000")
|
||||
t.Setenv("PGID", "bad")
|
||||
if err := applyPUIDPGID(); err == nil {
|
||||
t.Error("expected error for invalid PGID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-root skips chown", func(t *testing.T) {
|
||||
t.Setenv("PUID", "1000")
|
||||
t.Setenv("PGID", "1000")
|
||||
// tests run unprivileged; Getuid() != 0 => no chown, no error
|
||||
if err := applyPUIDPGID(); err != nil {
|
||||
t.Errorf("non-root should skip: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
"helix-proxy/internal/certificate"
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/proxy"
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
// run boots the admin API, proxy engine, HTTPS listener, and renewal loop until ctx is cancelled.
|
||||
func run(ctx context.Context) error {
|
||||
applyUmaskFromEnv()
|
||||
|
||||
if err := config.EnsureDataDirs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := applyPUIDPGID(); err != nil {
|
||||
slog.Warn("PUID/PGID apply partial", "err", err)
|
||||
}
|
||||
|
||||
dbPath := config.Resolve("db.bolt")
|
||||
slog.Info("using data dir", "data", config.DataDir(), "db", dbPath)
|
||||
|
||||
if config.IsDevelopment() {
|
||||
slog.Warn("PROXY_MODE=development (all letsencrypt certs will be self-signed test certs; not for production use)")
|
||||
} else {
|
||||
slog.Info("production mode (real LE only; no test cert simulation)")
|
||||
}
|
||||
|
||||
st, err := store.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eng := proxy.NewEngine(st)
|
||||
eng.ReloadFromStore()
|
||||
|
||||
cm := certificate.NewManager(st)
|
||||
jwtMgr := auth.NewJWTManager(os.Getenv("JWT_SECRET"))
|
||||
|
||||
go startRenewalLoop(ctx, cm, eng)
|
||||
|
||||
r := newAdminRouter(st, eng, cm, jwtMgr)
|
||||
|
||||
adminAddr := adminListenAddr()
|
||||
srv := &http.Server{
|
||||
Addr: adminAddr,
|
||||
Handler: r,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("admin UI + API listening", "addr", adminAddr, "note", "use ADMIN_PORT=81 in privileged env or docker for real port")
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server error", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
proxyCtx, proxyCancel := context.WithCancel(ctx)
|
||||
defer proxyCancel()
|
||||
|
||||
go func() {
|
||||
if err := eng.Start(proxyCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("proxy engine stopped", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
httpsAddr := proxyHTTPSListenAddr()
|
||||
slog.Info("starting HTTPS listener for cert-enabled hosts (pure Go tls termination)", "addr", httpsAddr)
|
||||
if err := serveHTTPS(proxyCtx, eng, httpsAddr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("https server error", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
slog.Info("shutting down...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
proxyCancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
slog.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// startRenewalLoop periodically renews LE certs until ctx is cancelled.
|
||||
func startRenewalLoop(ctx context.Context, cm *certificate.Manager, eng *proxy.Engine) {
|
||||
initialDelay := 5 * time.Second
|
||||
if d := os.Getenv("RENEWAL_INITIAL_DELAY"); d != "" {
|
||||
if parsed, err := time.ParseDuration(d); err == nil {
|
||||
initialDelay = parsed
|
||||
}
|
||||
}
|
||||
|
||||
renew := func() {
|
||||
cm.ProcessRenewals()
|
||||
eng.ReloadFromStore()
|
||||
}
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(initialDelay):
|
||||
renew()
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
renew()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"helix-proxy/internal/config"
|
||||
)
|
||||
|
||||
func TestRun_BootstrapAndShutdown(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
t.Setenv("DISABLE_IPV6", "1")
|
||||
t.Setenv("RENEWAL_INITIAL_DELAY", "1h") // avoid renewal work during short test
|
||||
|
||||
adminPort := pickFreePort(t)
|
||||
httpPort := pickFreePort(t)
|
||||
httpsPort := pickFreePort(t)
|
||||
t.Setenv("ADMIN_PORT", strconv.Itoa(adminPort))
|
||||
t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort))
|
||||
t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort))
|
||||
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- run(ctx)
|
||||
}()
|
||||
|
||||
adminAddr := "127.0.0.1:" + strconv.Itoa(adminPort)
|
||||
waitForTCP(t, adminAddr, 5*time.Second)
|
||||
waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpPort), 5*time.Second)
|
||||
waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpsPort), 5*time.Second)
|
||||
|
||||
resp, err := http.Get("http://" + adminAddr + "/api/")
|
||||
if err != nil {
|
||||
t.Fatalf("admin health: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin health status: %d", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("run did not exit after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_EnsureDataDirsFailure(t *testing.T) {
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", "/nonexistent-root/helix-proxy-test-"+strconv.Itoa(pickFreePort(t)))
|
||||
// Parent does not exist and cannot be created under /nonexistent-root.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
err := run(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when data dirs cannot be created")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
func pickFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
func waitForTCP(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("tcp %s not ready within %s", addr, timeout)
|
||||
}
|
||||
|
||||
func TestEngineStartup_HTTPAndHTTPS(t *testing.T) {
|
||||
t.Setenv("DISABLE_IPV6", "1")
|
||||
httpPort := pickFreePort(t)
|
||||
httpsPort := pickFreePort(t)
|
||||
t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort))
|
||||
t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort))
|
||||
|
||||
env := newAPITestServer(t)
|
||||
token := env.loginToken(t)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Upstream", "ok")
|
||||
_, _ = io.WriteString(w, "upstream-body")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse upstream: %v", err)
|
||||
}
|
||||
host, portStr, _ := net.SplitHostPort(u.Host)
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
domain := "startup-tls.example"
|
||||
certResp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
|
||||
"provider": "letsencrypt",
|
||||
"domainNames": []string{domain},
|
||||
}, token)
|
||||
if certResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("cert create: %d %s", certResp.StatusCode, readBody(certResp))
|
||||
}
|
||||
var cert store.Certificate
|
||||
decodeJSON(t, certResp, &cert)
|
||||
|
||||
phResp := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{domain},
|
||||
"forwardHost": host,
|
||||
"forwardPort": port,
|
||||
"forwardScheme": "http",
|
||||
"certificateId": cert.ID,
|
||||
"enabled": true,
|
||||
}, token)
|
||||
if phResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("proxy host: %d %s", phResp.StatusCode, readBody(phResp))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
httpAddr := "127.0.0.1:" + strconv.Itoa(httpPort)
|
||||
httpsAddr := proxyHTTPSListenAddr()
|
||||
|
||||
go func() {
|
||||
_ = env.Engine.Start(ctx)
|
||||
}()
|
||||
go func() {
|
||||
_ = serveHTTPS(ctx, env.Engine, httpsAddr)
|
||||
}()
|
||||
|
||||
waitForTCP(t, httpAddr, 5*time.Second)
|
||||
waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpsPort), 5*time.Second)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+httpAddr+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
req.Host = domain
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("http proxy: %v", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("http status: %d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if body != "upstream-body" {
|
||||
t.Errorf("http body: %q", body)
|
||||
}
|
||||
|
||||
httpsClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: domain,
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
httpsReq, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:"+strconv.Itoa(httpsPort)+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("https request: %v", err)
|
||||
}
|
||||
httpsReq.Host = domain
|
||||
httpsResp, err := httpsClient.Do(httpsReq)
|
||||
if err != nil {
|
||||
t.Fatalf("https proxy: %v", err)
|
||||
}
|
||||
httpsBody := readBody(httpsResp)
|
||||
if httpsResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("https status: %d body=%s", httpsResp.StatusCode, httpsBody)
|
||||
}
|
||||
if httpsBody != "upstream-body" {
|
||||
t.Errorf("https body: %q", httpsBody)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
"helix-proxy/internal/certificate"
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/proxy"
|
||||
"helix-proxy/internal/store"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// testServerMode selects API-only vs full admin router (API + SPA).
|
||||
type testServerMode int
|
||||
|
||||
const (
|
||||
testServerAPI testServerMode = iota
|
||||
testServerAdmin
|
||||
)
|
||||
|
||||
type apiTestEnv struct {
|
||||
BaseURL string
|
||||
Store store.Store
|
||||
Engine *proxy.Engine
|
||||
Certs *certificate.Manager
|
||||
JWT *auth.JWTManager
|
||||
}
|
||||
|
||||
// newTestServer boots a temp store/engine and httptest server with the chosen router.
|
||||
func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
|
||||
t.Helper()
|
||||
if _, ok := os.LookupEnv("PROXY_MODE"); !ok {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
if err := config.EnsureDataDirs(); err != nil {
|
||||
t.Fatalf("ensure dirs: %v", err)
|
||||
}
|
||||
st, err := store.New()
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
eng := proxy.NewEngine(st)
|
||||
eng.ReloadFromStore()
|
||||
cm := certificate.NewManager(st)
|
||||
jwtMgr := auth.NewJWTManager("test-jwt-secret")
|
||||
|
||||
var handler http.Handler
|
||||
switch mode {
|
||||
case testServerAdmin:
|
||||
handler = newAdminRouter(st, eng, cm, jwtMgr)
|
||||
default:
|
||||
r := chi.NewRouter()
|
||||
mountAPI(r, st, eng, cm, jwtMgr)
|
||||
handler = r
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(func() {
|
||||
srv.Close()
|
||||
if c, ok := st.(interface{ Close() error }); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
return &apiTestEnv{
|
||||
BaseURL: srv.URL,
|
||||
Store: st,
|
||||
Engine: eng,
|
||||
Certs: cm,
|
||||
JWT: jwtMgr,
|
||||
}
|
||||
}
|
||||
|
||||
func newAPITestServer(t *testing.T) *apiTestEnv {
|
||||
return newTestServer(t, testServerAPI)
|
||||
}
|
||||
|
||||
func newAdminTestServer(t *testing.T) *apiTestEnv {
|
||||
return newTestServer(t, testServerAdmin)
|
||||
}
|
||||
Reference in New Issue
Block a user