Compare commits

..

5 Commits

Author SHA1 Message Date
hyung-hwan b7031c1630 bundled xterm stuffs into a single file.
simplified http servers to cater for the single bundled file
2026-04-17 14:40:18 +09:00
hyung-hwan 5595c3813f added client token protection for rpty.
changed jwt-rs512 to jwt-rs256
2026-04-17 00:58:05 +09:00
hyung-hwan 134ef7feec ported the xinfo passing between http handlers to the client's http server side 2026-04-16 21:39:46 +09:00
hyung-hwan 0c8fb18a7a added iat and exp to protected client token for rpx 2026-04-16 21:30:54 +09:00
hyung-hwan 14b5b82881 added the protection for client token in rpx handler 2026-04-16 21:10:51 +09:00
29 changed files with 1643 additions and 358 deletions
+8 -12
View File
@@ -26,6 +26,7 @@ SRCS=\
jwt.go \
packet.go \
pty.go \
rsa-aes.go \
server.go \
server-ctl.go \
server-cts-rpty.go \
@@ -42,9 +43,8 @@ SRCS=\
transform.go \
DATA = \
xterm.css \
xterm.js \
xterm-addon-fit.js \
xterm-plain.js \
xterm-plain.html \
xterm.html
CMD_DATA=\
@@ -84,15 +84,11 @@ hodu_grpc.pb.go: hodu.proto
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
hodu.proto
xterm.js:
curl -L -o "$@" https://cdn.jsdelivr.net/npm/@xterm/xterm/lib/xterm.min.js
xterm-addon-fit.js:
curl -L -o "$@" https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.min.js
xterm.css:
curl -L -o "$@" https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.min.css
sed -r -i 's|^/\*# sourceMappingURL=/.+ \*/$$||g' "$@"
xterm.html: xterm-plain.html xterm-plain.js vite.config.js package.json
rm -rf .vite-xterm
npm run build:xterm
cp .vite-xterm/xterm-plain.html "$@"
rm -rf .vite-xterm
cmd/tls.crt:
openssl req -x509 -newkey rsa:4096 -keyout cmd/tls.key -out cmd/tls.crt -sha256 -days 36500 -nodes -subj "/CN=$(NAME)" --addext "subjectAltName=DNS:$(NAME),IP:127.0.0.1,IP:::1"
+26
View File
@@ -21,6 +21,32 @@ On the client-side:
- curl -v -H 'Host: tratra' http://127.0.0.1:9996/hello/world
- this hits the port 1212 on the client side
- If you need to enable protection, you need a rsa key pair.
- openssl genrsa -traditional -out private.pem 2048
- openssl rsa -pubout -in private.pem -out public.pem
- You should generate a encrypted client token and use it.
- python rsa-aes-256-gcm.py encipher public.pem client-token-value
- php rsa-aes-256-gcm.php encipher public.pem client-token-value
Given the following configuration snippet:
```
rpx:
client-token:
attr-name: X-Client-Token
regex:
submatch-index: 1
protection: rsa-aes-256-gcm
token-rsa-key-text:
token-rsa-key-file: ./private.pem
```
The http/https caller can add the `X-Client-Token` header in the request as generated by the rsa-aes-256-gcm utility.
The format of the protected token is:
- ciphertext = b64url(token)|iat|exp
- encrypted-aes-key = randomly generated key encrypted with a rsa public key
- protected-token = b64url(encrypted-aes-key).b64url(nonce).b64url(ciphertext)
### WPX
- On the client side
- python -m http.server 1212
+11 -11
View File
@@ -197,7 +197,7 @@ func (ctl *client_ctl) Cors(req *http.Request) bool {
func (ctl *client_ctl) Authenticate(req *http.Request) (int, string) {
if ctl.c.ctl_auth == nil { return http.StatusOK, "" }
return ctl.c.ctl_auth.Authenticate(req)
return ctl.c.ctl_auth.Authenticate(req, "")
}
// ------------------------------------
@@ -229,7 +229,7 @@ func (ctl *client_ctl_token) ServeHTTP(w http.ResponseWriter, req *http.Request)
claim.IssuedAt = now.Unix()
claim.ExpiresAt = now.Add(c.ctl_auth.TokenTtl).Unix()
jwt = NewJWT(c.ctl_auth.TokenRsaKey, &claim)
tok, err = jwt.SignRS512()
tok, err = jwt.SignRS256()
if err != nil {
status_code = WriteJsonRespHeader(w, http.StatusInternalServerError)
je.Encode(JsonErrmsg{Text: err.Error()})
@@ -237,7 +237,7 @@ func (ctl *client_ctl_token) ServeHTTP(w http.ResponseWriter, req *http.Request)
}
status_code = WriteJsonRespHeader(w, http.StatusOK)
err = je.Encode(json_out_token{ AccessToken: tok }) // TODO: refresh token
err = je.Encode(json_out_auth_token{ AccessToken: tok }) // TODO: refresh token
if err != nil { goto oops }
default:
@@ -1179,24 +1179,24 @@ func (ctl *client_ctl_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
// handle authentication using the first message.
// end this task if authentication fails.
if !ctl.noauth && c.ctl_auth != nil {
/*
var req *http.Request
req = ws.Request()
if req.Header.Get("Authorization") == "" {
var token string
token = req.FormValue("token")
if token != "" {
var access_token string
access_token = req.FormValue("access-token") // this is an authorization token
if access_token != "" {
// websocket doesn't actual have extra headers except a few fixed
// ones. add "Authorization" header from the query paramerer and
// compose a fake header to reuse the same Authentication() function
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", access_token))
}
}
*/
status_code, _ = c.ctl_auth.Authenticate(req)
if status_code != http.StatusOK {
goto done
}
status_code, _ = c.ctl_auth.Authenticate(ws.Request(), "access-token")
if status_code != http.StatusOK { goto done }
}
sbsc, err = c.bulletin.Subscribe("")
+35 -4
View File
@@ -3,6 +3,7 @@ package hodu
import "encoding/base64"
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "net/http"
import "os"
@@ -50,6 +51,19 @@ func (pty *client_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
c = pty.C
req = ws.Request()
// handle authentication using the first message.
// end this task if authentication fails.
if c.ctl_auth != nil {
var status_code int
var msg string
status_code, msg = c.ctl_auth.Authenticate(req, "access-token")
if status_code != http.StatusOK {
ws.Close()
return status_code, fmt.Errorf("failed to authenticate - %s", msg)
}
}
conn_ready_chan = make(chan bool, 3)
wg.Add(1)
@@ -71,8 +85,8 @@ func (pty *client_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
var err error
poll_fds = []unix.PollFd{
unix.PollFd{Fd: int32(out.Fd()), Events: unix.POLLIN},
unix.PollFd{Fd: int32(pfd[0]), Events: unix.POLLIN},
unix.PollFd{Fd: int32(out.Fd()), Events: unix.POLLIN},
}
c.stats.pty_sessions.Add(1)
@@ -87,8 +101,8 @@ func (pty *client_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
continue
}
out_revents = poll_fds[0].Revents
sig_revents = poll_fds[1].Revents
sig_revents = poll_fds[0].Revents
out_revents = poll_fds[1].Revents
if (out_revents & unix.POLLIN) != 0 {
n, err = out.Read(buf[:])
@@ -262,6 +276,21 @@ done:
// ------------------------------------------------------
func (pty *client_pty_xterm_file) Authenticate(req *http.Request) (int, string) {
if pty.file == "xterm.html" {
// this is not a real api call. but at least for xterm.html,
// i don't bypass authentication and and in addition,
// i check the value of the "access-token" parameter for
// jwt authentication if it exists
return pty.c.ctl_auth.Authenticate(req, "access-token")
}
// you can download other files without authentication
// even if authentication is enabled. but this part must not be reached
// because xterm.html is the only meanful resource as of now
return http.StatusOK, ""
}
func (pty *client_pty_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Request) (int, error) {
var c *Client
var status_code int
@@ -270,6 +299,7 @@ func (pty *client_pty_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
c = pty.c
switch pty.file {
/*
case "xterm.js":
status_code = WriteJsRespHeader(w, http.StatusOK)
w.Write(xterm_js)
@@ -282,10 +312,11 @@ func (pty *client_pty_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
case "xterm.css":
status_code = WriteCssRespHeader(w, http.StatusOK)
w.Write(xterm_css)
*/
case "xterm.html":
var tmpl *template.Template
tmpl = template.New("")
tmpl = template.New("").Delims("{{@@", "@@}}")
if c.xterm_html != "" {
_, err = tmpl.Parse(c.xterm_html)
} else {
+9 -2
View File
@@ -1710,6 +1710,8 @@ func (c *Client) WrapHttpHandler(handler ClientHttpHandler) http.Handler {
var err error
var start_time time.Time
var time_taken time.Duration
var newctx context.Context
var xinfo HttpHandlerExtraInfo
// this deferred function is to overcome the recovering implemenation
// from panic done in go's http server. in that implemenation, panic
@@ -1723,6 +1725,9 @@ func (c *Client) WrapHttpHandler(handler ClientHttpHandler) http.Handler {
start_time = time.Now()
newctx = context.WithValue(req.Context(), http_handler_extra_info_key, &xinfo)
req = req.WithContext(newctx)
if handler.Cors(req) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "*")
@@ -1750,10 +1755,12 @@ func (c *Client) WrapHttpHandler(handler ClientHttpHandler) http.Handler {
time_taken = time.Since(start_time) //time.Now().Sub(start_time)
if status_code > 0 {
var id string = handler.Identity()
if xinfo.extra_id != "" { id = id + "/" + xinfo.extra_id }
if err != nil {
c.log.Write(handler.Identity(), LOG_INFO, "[%s] %s %s %d %.9f - %s", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds(), err.Error())
c.log.Write(id, LOG_INFO, "[%s] %s %s %d %.9f - %s", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds(), err.Error())
} else {
c.log.Write(handler.Identity(), LOG_INFO, "[%s] %s %s %d %.9f", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds())
c.log.Write(id, LOG_INFO, "[%s] %s %s %d %.9f", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds())
}
}
})
+45 -23
View File
@@ -16,6 +16,14 @@ import "time"
import yaml "github.com/goccy/go-yaml"
type ServerRptyConfig struct {
ClientToken struct {
Protection string `yaml:"protection"`
TokenRsaKeyText string `yaml:"token-rsa-key-text"`
TokenRsaKeyFile string `yaml:"token-rsa-key-file"`
} `yaml:"client-token"`
}
type ServerTLSConfig struct {
Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert-file"`
@@ -75,6 +83,9 @@ type RPXServiceConfig struct {
type RPXClientTokenConfig struct {
AttrName string `yaml:"attr-name"`
Protection string `yaml:"protection"`
TokenRsaKeyText string `yaml:"token-rsa-key-text"`
TokenRsaKeyFile string `yaml:"token-rsa-key-file"`
Regex string `yaml:"regex"`
SubmatchIndex int `yaml:"submatch-index"`
}
@@ -147,6 +158,7 @@ type ServerConfig struct {
CTL struct {
Service CTLServiceConfig `yaml:"service"`
TLS ServerTLSConfig `yaml:"tls"`
Rpty ServerRptyConfig `yaml:"rpty"`
} `yaml:"ctl"`
RPX struct {
@@ -392,15 +404,44 @@ func make_tls_client_config(cfg *ClientTLSConfig) (*tls.Config, error) {
return tlscfg, nil
}
// --------------------------------------------------------------------
func make_rsa_private_key_config(key_text string, key_file string, default_key_text []byte, key_name string) (*rsa.PrivateKey, error) {
var rsa_key_text []byte
var rk *rsa.PrivateKey
var pb *pem.Block
var b []byte
var err error
if key_text == "" && key_file != "" {
rsa_key_text, err = os.ReadFile(key_file)
if err != nil {
return nil, fmt.Errorf("unable to read %s - %s", key_file, err.Error())
}
}
if len(rsa_key_text) == 0 { rsa_key_text = []byte(key_text) }
if len(rsa_key_text) == 0 { rsa_key_text = default_key_text }
if len(rsa_key_text) == 0 { return nil, nil }
pb, b = pem.Decode(rsa_key_text)
if pb == nil || len(b) > 0 {
return nil, fmt.Errorf("invalid %s text %.32s... - no block or too many blocks", key_name, string(rsa_key_text))
}
rk, err = x509.ParsePKCS1PrivateKey(pb.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid %s text %.32s... - %s", key_name, string(rsa_key_text), err.Error())
}
return rk, nil
}
// --------------------------------------------------------------------
func make_http_auth_config(cfg *HttpAuthConfig) (*hodu.HttpAuthConfig, error) {
var config hodu.HttpAuthConfig
var cred string
var b []byte
var x []string
var rsa_key_text []byte
var rk *rsa.PrivateKey
var pb *pem.Block
var rule HttpAccessRule
var idx int
var err error
@@ -428,27 +469,8 @@ func make_http_auth_config(cfg *HttpAuthConfig) (*hodu.HttpAuthConfig, error) {
}
// load rsa key
if cfg.TokenRsaKeyText == "" && cfg.TokenRsaKeyFile != "" {
rsa_key_text, err = os.ReadFile(cfg.TokenRsaKeyFile)
if err != nil {
return nil, fmt.Errorf("unable to read %s - %s", cfg.TokenRsaKeyFile, err.Error())
}
}
if len(rsa_key_text) == 0 { rsa_key_text = []byte(cfg.TokenRsaKeyText) }
if len(rsa_key_text) == 0 { rsa_key_text = hodu_rsa_key_text }
pb, b = pem.Decode(rsa_key_text)
if pb == nil || len(b) > 0 {
// show up to first 8 characters only
return nil, fmt.Errorf("invalid token rsa key text %.32s... - no block or too many blocks", string(rsa_key_text))
}
rk, err = x509.ParsePKCS1PrivateKey(pb.Bytes)
if err != nil {
// show up to first 8 characters only
return nil, fmt.Errorf("invalid token rsa key text %.32s... - %s", string(rsa_key_text), err.Error())
}
rk, err = make_rsa_private_key_config(cfg.TokenRsaKeyText, cfg.TokenRsaKeyFile, hodu_rsa_key_text, "token rsa key")
if err != nil { return nil, err }
config.TokenRsaKey = rk
// load access rules
+9
View File
@@ -137,7 +137,16 @@ func server_main(ctl_addrs []string, rpc_addrs []string, rpx_addrs[] string, pxy
if len(config.PxyAddrs) <= 0 { config.PxyAddrs = cfg.PXY.Service.Addrs }
if len(config.WpxAddrs) <= 0 { config.WpxAddrs = cfg.WPX.Service.Addrs }
config.RptyClientTokenProtection = cfg.CTL.Rpty.ClientToken.Protection
config.RptyClientTokenRsaKey, err = make_rsa_private_key_config(cfg.CTL.Rpty.ClientToken.TokenRsaKeyText, cfg.CTL.Rpty.ClientToken.TokenRsaKeyFile, nil, "rpx client token rsa key")
config.RpxClientTokenAttrName = cfg.RPX.ClientToken.AttrName
config.RpxClientTokenProtection = cfg.RPX.ClientToken.Protection
config.RpxClientTokenRsaKey, err = make_rsa_private_key_config(cfg.RPX.ClientToken.TokenRsaKeyText, cfg.RPX.ClientToken.TokenRsaKeyFile, nil, "rpx client token rsa key")
if err != nil { return err }
if strings.EqualFold(config.RpxClientTokenProtection, "rsa-aes-256-gcm") && config.RpxClientTokenRsaKey == nil {
return fmt.Errorf("missing rpx client token rsa key for protection %s", config.RpxClientTokenProtection)
}
if cfg.RPX.ClientToken.Regex != "" {
config.RpxClientTokenRegex, err = regexp.Compile(cfg.RPX.ClientToken.Regex)
if err != nil { return err }
+68 -12
View File
@@ -18,6 +18,8 @@ import "strings"
import "sync"
import "time"
import "golang.org/x/sys/unix"
const HODU_RPC_VERSION uint32 = 0x010000
type LogLevel int
@@ -56,6 +58,13 @@ type Service interface {
WriteLog(id string, level LogLevel, fmtstr string, args ...interface{})
}
type HttpHandlerExtraInfo struct {
// more information about the request processed
extra_id string
}
type http_request_context_key int
const http_handler_extra_info_key http_request_context_key = iota
type HttpAccessAction int
const (
HTTP_ACCESS_ACCEPT HttpAccessAction = iota
@@ -125,6 +134,7 @@ type json_xterm_ws_event struct {
// ---------------------------------------------------------
/*
//go:embed xterm.js
var xterm_js []byte
//go:embed xterm-addon-fit.js
@@ -133,6 +143,7 @@ var xterm_addon_fit_js []byte
var xterm_addon_unicode11_js []byte
//go:embed xterm.css
var xterm_css []byte
*/
//go:embed xterm.html
var xterm_html string
@@ -369,7 +380,26 @@ func (stats *json_out_go_stats) from_runtime_stats() {
// ------------------------------------
func (auth *HttpAuthConfig) Authenticate(req *http.Request) (int, string) {
func (auth *HttpAuthConfig) check_auth_token(jwt_token string) error {
var jwt *JWT[ServerTokenClaim]
var claim ServerTokenClaim
var err error
jwt = NewJWT(auth.TokenRsaKey, &claim)
err = jwt.VerifyRS256(strings.TrimSpace(jwt_token))
if err == nil {
// verification ok. let's check the actual payload
var now time.Time
now = time.Now()
// TODO: subject check. other claim check
if !now.Before(time.Unix(claim.IssuedAt, 0)) && now.Before(time.Unix(claim.ExpiresAt, 0)) { return nil } // not expired
return fmt.Errorf("auth token expired");
} else {
return fmt.Errorf("failed to verify auth token");
}
}
func (auth *HttpAuthConfig) Authenticate(req *http.Request, auth_token_param_name string) (int, string) {
var rule HttpAccessRule
var raddrport netip.AddrPort
var raddr netip.Addr
@@ -423,19 +453,21 @@ func (auth *HttpAuthConfig) Authenticate(req *http.Request) (int, string) {
auth_hdr = req.Header.Get("Authorization")
if auth_hdr != "" {
var auth_parts []string
auth_parts = strings.Fields(auth_hdr)
if len(auth_parts) == 2 && strings.EqualFold(auth_parts[0], "Bearer") && auth.TokenRsaKey != nil {
var jwt *JWT[ServerTokenClaim]
var claim ServerTokenClaim
jwt = NewJWT(auth.TokenRsaKey, &claim)
err = jwt.VerifyRS512(strings.TrimSpace(auth_parts[1]))
if err == nil {
// verification ok. let's check the actual payload
var now time.Time
now = time.Now()
if !now.Before(time.Unix(claim.IssuedAt, 0)) && now.Before(time.Unix(claim.ExpiresAt, 0)) { return http.StatusOK, "" } // not expired
}
err = auth.check_auth_token(auth_parts[1])
if err != nil { return http.StatusUnauthorized, err.Error() }
return http.StatusOK, ""
}
} else if auth_token_param_name != "" {
// there is no Authorization header.
// but there may be a token parameter.
var auth_token string
auth_token = req.FormValue(auth_token_param_name)
if auth_token != "" {
err = auth.check_auth_token(auth_token)
if err != nil { return http.StatusUnauthorized, err.Error() }
return http.StatusOK, ""
}
}
@@ -582,3 +614,27 @@ func read_line_limited(r *bufio.Reader, max int) (string, error) {
return string(line), err
}
}
func read_fd_full(fd int, buf []byte) error {
var off int
off = 0
for off < len(buf) {
n, err := unix.Read(fd, buf[off:])
if err != nil {
if errors.Is(err, unix.EINTR) { continue }
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
// for nonblocking fds, wait/retry as appropriate
continue
}
return err
}
if n == 0 {
if off == len(buf) { return nil }
return io.ErrUnexpectedEOF
}
off += n
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
b64() { openssl base64 -e -A | tr '+/' '-_' | tr -d '='; }
usage() {
echo "Usage: $0 generate key-file [ttl-seconds]" >&2
exit 1
}
cmd="$1"
key_file="$2"
ttl="$3"
if [ "$cmd" != "generate" ]; then
usage
fi
if [ -z "$key_file" ]; then
usage
fi
if [ -z "$ttl" ]; then
ttl=3600
fi
case "$ttl" in
''|*[!0-9]*)
echo "invalid ttl-seconds: $ttl" >&2
exit 1
;;
esac
now=$(date +%s)
exp=$((now + ttl))
H=$(printf '{"alg":"RS256","typ":"JWT"}' | b64)
P=$(printf '{"sub":"123","iat":%s,"exp":%s}' "$now" "$exp" | b64)
D="$H.$P"
S=$(printf '%s' "$D" | openssl dgst -sha256 -sign "$key_file" | b64)
echo "$H.$P.$S"
+16 -16
View File
@@ -14,35 +14,35 @@ import "strings"
func Sign(data []byte, privkey *rsa.PrivateKey) ([]byte, error) {
var h hash.Hash
h = crypto.SHA512.New()
h = crypto.SHA256.New()
h.Write(data)
//fmt.Printf("%+v\n", h.Sum(nil))
return rsa.SignPKCS1v15(rand.Reader, privkey, crypto.SHA512, h.Sum(nil))
return rsa.SignPKCS1v15(rand.Reader, privkey, crypto.SHA256, h.Sum(nil))
}
func Verify(data []byte, pubkey *rsa.PublicKey, sig []byte) error {
var h hash.Hash
h = crypto.SHA512.New()
h = crypto.SHA256.New()
h.Write(data)
return rsa.VerifyPKCS1v15(pubkey, crypto.SHA512, h.Sum(nil), sig)
return rsa.VerifyPKCS1v15(pubkey, crypto.SHA256, h.Sum(nil), sig)
}
func SignHS512(data []byte, key string) ([]byte, error) {
func SignHS256(data []byte, key string) ([]byte, error) {
var h hash.Hash
h = hmac.New(crypto.SHA512.New, []byte(key))
h = hmac.New(crypto.SHA256.New, []byte(key))
h.Write(data)
return h.Sum(nil), nil
}
func VerifyHS512(data []byte, key string, sig []byte) error {
func VerifyHS256(data []byte, key string, sig []byte) error {
var h hash.Hash
h = crypto.SHA512.New()
h = crypto.SHA256.New()
h.Write(data)
if !hmac.Equal(h.Sum(nil), sig) { return fmt.Errorf("invalid signature") }
@@ -66,7 +66,7 @@ func NewJWT[T any](key *rsa.PrivateKey, claims *T) *JWT[T] {
return &JWT[T]{key: key, claims: claims}
}
func (j *JWT[T]) SignRS512() (string, error) {
func (j *JWT[T]) SignRS256() (string, error) {
var h JWTHeader
var hb []byte
var cb []byte
@@ -75,7 +75,7 @@ func (j *JWT[T]) SignRS512() (string, error) {
var hs hash.Hash
var err error
h.Algo = "RS512"
h.Algo = "RS256"
h.Type = "JWT"
hb, err = json.Marshal(h)
@@ -86,17 +86,17 @@ func (j *JWT[T]) SignRS512() (string, error) {
ss = base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(cb)
hs = crypto.SHA512.New()
hs = crypto.SHA256.New()
hs.Write([]byte(ss))
sb, err = rsa.SignPKCS1v15(rand.Reader, j.key, crypto.SHA512, hs.Sum(nil))
sb, err = rsa.SignPKCS1v15(rand.Reader, j.key, crypto.SHA256, hs.Sum(nil))
if err != nil { return "", err }
//fmt.Printf ("%+v %+v %s\n", string(hb), string(cb), (ss + "." + base64.RawURLEncoding.EncodeToString(sb)))
return ss + "." + base64.RawURLEncoding.EncodeToString(sb), nil
}
func (j *JWT[T]) VerifyRS512(tok string) error {
func (j *JWT[T]) VerifyRS256(tok string) error {
var segs []string
var hb []byte
var cb []byte
@@ -113,7 +113,7 @@ func (j *JWT[T]) VerifyRS512(tok string) error {
err = json.Unmarshal(hb, &jh)
if err != nil { return fmt.Errorf("invalid header - %s", err.Error()) }
if jh.Algo != "RS512" || jh.Type != "JWT" { return fmt.Errorf("invalid header content %+v", jh) }
if jh.Algo != "RS256" || jh.Type != "JWT" { return fmt.Errorf("invalid header content %+v", jh) }
cb, err = base64.RawURLEncoding.DecodeString(segs[1])
if err != nil { return fmt.Errorf("invalid claims - %s", err.Error()) }
@@ -123,11 +123,11 @@ func (j *JWT[T]) VerifyRS512(tok string) error {
ss, err = base64.RawURLEncoding.DecodeString(segs[2])
if err != nil { return fmt.Errorf("invalid signature - %s", err.Error()) }
hs = crypto.SHA512.New()
hs = crypto.SHA256.New()
hs.Write([]byte(segs[0]))
hs.Write([]byte("."))
hs.Write([]byte(segs[1]))
err = rsa.VerifyPKCS1v15(&j.key.PublicKey, crypto.SHA512, hs.Sum(nil), ss)
err = rsa.VerifyPKCS1v15(&j.key.PublicKey, crypto.SHA256, hs.Sum(nil), ss)
if err != nil { return fmt.Errorf("unverifiable signature - %s", err.Error()) }
return nil
+2 -2
View File
@@ -28,13 +28,13 @@ func TestJwt(t *testing.T) {
var j *hodu.JWT[jwt_claim]
j = hodu.NewJWT(key, &jc)
tok, err = j.SignRS512()
tok, err = j.SignRS256()
if err != nil {
t.Fatalf("signing failure - %s", err.Error())
}
jc = jwt_claim{}
err = j.VerifyRS512(tok)
err = j.VerifyRS256(tok)
if err != nil {
t.Fatalf("verification failure - %s", err.Error())
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "hodu-xterm-bundle",
"private": true,
"type": "module",
"scripts": {
"build:xterm": "vite build"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.0.0"
}
}
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
function fail(string $msg): void {
fwrite(STDERR, $msg . PHP_EOL);
exit(1);
}
function usage(): void {
fail(
"USAGE: rsa-aes-256-gcm.php encipher key-file token [ttl-seconds]\n" .
" rsa-aes-256-gcm.php decipher key-file [document]\n\n" .
"If document is omitted, stdin is used. ttl-seconds defaults to 30."
);
}
function load_key_text(string $path): string {
$text = @file_get_contents($path);
if ($text === false) {
fail("unable to read {$path}");
}
return $text;
}
function load_private_key(string $path) {
$key = openssl_pkey_get_private(load_key_text($path));
if ($key === false) {
fail("unable to load private key from {$path}");
}
return $key;
}
function load_public_key(string $path) {
$text = load_key_text($path);
$key = openssl_pkey_get_public($text);
if ($key !== false) {
return $key;
}
$private_key = openssl_pkey_get_private($text);
if ($private_key === false) {
fail("unable to load public key from {$path}");
}
$details = openssl_pkey_get_details($private_key);
if ($details === false || !isset($details["key"])) {
fail("unable to extract public key from {$path}");
}
$key = openssl_pkey_get_public($details["key"]);
if ($key === false) {
fail("unable to extract public key from {$path}");
}
return $key;
}
function mgf1_sha256(string $seed, int $length): string {
$output = "";
$counter = 0;
while (strlen($output) < $length) {
$c = pack("N", $counter);
$output .= hash("sha256", $seed . $c, true);
$counter++;
}
return substr($output, 0, $length);
}
function oaep_encode_sha256(string $msg, int $k): string {
$label_hash = hash("sha256", "", true);
$h_len = strlen($label_hash);
$m_len = strlen($msg);
$ps_len = $k - $m_len - (2 * $h_len) - 2;
if ($ps_len < 0) {
fail("message too long for rsa key");
}
$db = $label_hash . str_repeat("\x00", $ps_len) . "\x01" . $msg;
$seed = random_bytes($h_len);
$db_mask = mgf1_sha256($seed, $k - $h_len - 1);
$masked_db = $db ^ $db_mask;
$seed_mask = mgf1_sha256($masked_db, $h_len);
$masked_seed = $seed ^ $seed_mask;
return "\x00" . $masked_seed . $masked_db;
}
function oaep_decode_sha256(string $em, int $k): string {
$label_hash = hash("sha256", "", true);
$h_len = strlen($label_hash);
$em_len = strlen($em);
$one_pos = -1;
if ($em_len !== $k || $k < (2 * $h_len) + 2) {
fail("invalid oaep block size");
}
if ($em[0] !== "\x00") {
fail("invalid oaep prefix");
}
$masked_seed = substr($em, 1, $h_len);
$masked_db = substr($em, 1 + $h_len);
$seed_mask = mgf1_sha256($masked_db, $h_len);
$seed = $masked_seed ^ $seed_mask;
$db_mask = mgf1_sha256($seed, $k - $h_len - 1);
$db = $masked_db ^ $db_mask;
if (substr($db, 0, $h_len) !== $label_hash) {
fail("invalid oaep label hash");
}
for ($i = $h_len; $i < strlen($db); $i++) {
if ($db[$i] === "\x01") {
$one_pos = $i;
break;
}
if ($db[$i] !== "\x00") {
fail("invalid oaep padding");
}
}
if ($one_pos < 0) {
fail("invalid oaep separator");
}
return substr($db, $one_pos + 1);
}
function rsa_oaep_sha256_encrypt($public_key, string $plaintext): string {
$details = openssl_pkey_get_details($public_key);
$ciphertext = "";
$ok = false;
if ($details === false || !isset($details["bits"])) {
fail("unable to get rsa public key details");
}
$encoded = oaep_encode_sha256($plaintext, intdiv((int)$details["bits"] + 7, 8));
$ok = openssl_public_encrypt($encoded, $ciphertext, $public_key, OPENSSL_NO_PADDING);
if (!$ok) {
fail("rsa encryption failed");
}
return $ciphertext;
}
function rsa_oaep_sha256_decrypt($private_key, string $ciphertext): string {
$details = openssl_pkey_get_details($private_key);
$encoded = "";
$ok = false;
if ($details === false || !isset($details["bits"])) {
fail("unable to get rsa private key details");
}
$ok = openssl_private_decrypt($ciphertext, $encoded, $private_key, OPENSSL_NO_PADDING);
if (!$ok) {
fail("rsa decryption failed");
}
return oaep_decode_sha256($encoded, intdiv((int)$details["bits"] + 7, 8));
}
function encode_url_base64(string $data): string {
return rtrim(strtr(base64_encode($data), "+/", "-_"), "=");
}
function decode_url_base64(string $data) {
$padding_len = 0;
$decoded = "";
$padding_len = (4 - (strlen($data) % 4)) % 4;
$decoded = base64_decode(strtr($data . str_repeat("=", $padding_len), "-_", "+/"), true);
return $decoded;
}
function encipher($public_key, string $text): string {
$aes_key = random_bytes(32);
$nonce = random_bytes(12);
$tag = "";
$ciphertext = openssl_encrypt($text, "aes-256-gcm", $aes_key, OPENSSL_RAW_DATA, $nonce, $tag, "", 16);
if ($ciphertext === false) {
fail("aes-gcm encryption failed");
}
$encrypted_key = rsa_oaep_sha256_encrypt($public_key, $aes_key);
$ciphertext .= $tag;
return encode_url_base64($encrypted_key) . "." . encode_url_base64($nonce) . "." . encode_url_base64($ciphertext);
}
function decipher($private_key, string $doc): string {
$parts = explode(".", $doc, 3);
if (count($parts) !== 3) {
fail("invalid serialized token document");
}
$encrypted_key = decode_url_base64($parts[0]);
$nonce = decode_url_base64($parts[1]);
$ciphertext_and_tag = decode_url_base64($parts[2]);
if ($encrypted_key === false) {
fail("invalid encrypted key");
}
if ($nonce === false) {
fail("invalid nonce");
}
if ($ciphertext_and_tag === false) {
fail("invalid ciphertext");
}
if (strlen($nonce) !== 12) {
fail("invalid nonce size");
}
if (strlen($ciphertext_and_tag) < 16) {
fail("invalid ciphertext size");
}
$aes_key = rsa_oaep_sha256_decrypt($private_key, $encrypted_key);
$tag = substr($ciphertext_and_tag, -16);
$ciphertext = substr($ciphertext_and_tag, 0, -16);
$plaintext = openssl_decrypt($ciphertext, "aes-256-gcm", $aes_key, OPENSSL_RAW_DATA, $nonce, $tag, "");
if ($plaintext === false) {
fail("aes-gcm decryption failed");
}
return $plaintext;
}
function make_token_payload(string $token, int $now, int $ttl): string {
return rtrim(strtr(base64_encode($token), "+/", "-_"), "=") . "|" . (string)$now . "|" . (string)($now + $ttl);
}
function parse_token_payload(string $payload): string {
$parts = explode("|", $payload, 3);
$token = "";
$padding_len = 0;
if (count($parts) !== 3) {
fail("invalid protected token payload");
}
$padding_len = (4 - (strlen($parts[0]) % 4)) % 4;
$token = base64_decode(strtr($parts[0] . str_repeat("=", $padding_len), "-_", "+/"), true);
if ($token === false) {
fail("invalid protected token text");
}
return $token . "|" . $parts[1] . "|" . $parts[2];
}
if ($argc < 3) {
usage();
}
$mode = $argv[1];
$key_file = $argv[2];
if ($mode === "encipher") {
$input = "";
$ttl = 30;
$now = time();
$public_key = load_public_key($key_file);
if ($argc >= 4) {
$input = $argv[3];
} else {
fail("missing token");
}
if ($argc >= 5) {
if (!ctype_digit($argv[4])) {
fail("invalid ttl-seconds");
}
$ttl = (int)$argv[4];
}
echo encipher($public_key, make_token_payload($input, $now, $ttl)) . PHP_EOL;
exit(0);
}
if ($mode === "decipher") {
$input = $argc >= 4 ? $argv[3] : stream_get_contents(STDIN);
$private_key = load_private_key($key_file);
if ($input === false) {
fail("failed to read input");
}
echo parse_token_payload(decipher($private_key, $input)) . PHP_EOL;
exit(0);
}
usage();
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
import base64
import os
import sys
import time
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.serialization import load_pem_public_key
def fail(msg: str) -> None:
sys.stderr.write(msg + "\n")
sys.exit(1)
def usage() -> None:
fail(
"USAGE: rsa-aes-256-gcm.py encipher key-file token [ttl-seconds]\n"
" rsa-aes-256-gcm.py decipher key-file [document]\n\n"
"If document is omitted, stdin is used. ttl-seconds defaults to 30."
)
def load_key_text(path: str) -> bytes:
try:
return open(path, "rb").read()
except OSError:
fail(f"unable to read {path}")
def load_private_key(path: str):
try:
return load_pem_private_key(load_key_text(path), password=None)
except ValueError:
fail(f"unable to load private key from {path}")
def load_public_key(path: str):
key_text = load_key_text(path)
try:
return load_pem_public_key(key_text)
except ValueError:
pass
try:
private_key = load_pem_private_key(key_text, password=None)
except ValueError:
fail(f"unable to load public key from {path}")
return private_key.public_key()
def rsa_oaep_sha256_encrypt(public_key, plaintext: bytes) -> bytes:
if not isinstance(public_key, rsa.RSAPublicKey):
fail("unable to get rsa public key details")
try:
return public_key.encrypt(
plaintext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
except ValueError:
fail("rsa encryption failed")
def rsa_oaep_sha256_decrypt(private_key, ciphertext: bytes) -> bytes:
if not isinstance(private_key, rsa.RSAPrivateKey):
fail("unable to get rsa private key details")
try:
return private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
except ValueError:
fail("rsa decryption failed")
def encode_url_base64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def decode_url_base64(text: str) -> bytes:
padding_len = (-len(text)) % 4
try:
return base64.b64decode(text + ("=" * padding_len), altchars=b"-_", validate=True)
except Exception:
raise ValueError()
def encipher(public_key, text: str) -> str:
aes_key = os.urandom(32)
nonce = os.urandom(12)
ciphertext = AESGCM(aes_key).encrypt(nonce, text.encode("utf-8"), None)
encrypted_key = rsa_oaep_sha256_encrypt(public_key, aes_key)
return (
encode_url_base64(encrypted_key)
+ "."
+ encode_url_base64(nonce)
+ "."
+ encode_url_base64(ciphertext)
)
def decipher(private_key, doc: str) -> str:
parts = doc.split(".", 2)
if len(parts) != 3:
fail("invalid serialized token document")
try:
encrypted_key = decode_url_base64(parts[0])
except Exception:
fail("invalid encrypted key")
try:
nonce = decode_url_base64(parts[1])
except Exception:
fail("invalid nonce")
try:
ciphertext = decode_url_base64(parts[2])
except Exception:
fail("invalid ciphertext")
if len(nonce) != 12:
fail(f"invalid nonce size")
if len(ciphertext) < 16:
fail("invalid ciphertext size")
aes_key = rsa_oaep_sha256_decrypt(private_key, encrypted_key)
try:
plaintext = AESGCM(aes_key).decrypt(nonce, ciphertext, None)
except Exception:
fail("aes-gcm decryption failed")
return plaintext.decode("utf-8")
def make_token_payload(token: str, now: int, ttl: int) -> str:
return (
base64.urlsafe_b64encode(token.encode("utf-8")).decode("ascii").rstrip("=")
+ "|"
+ str(now)
+ "|"
+ str(now + ttl)
)
def parse_token_payload(payload: str) -> str:
parts = payload.split("|", 2)
padding_len = 0
if len(parts) != 3:
fail("invalid protected token payload")
padding_len = (-len(parts[0])) % 4
try:
token = base64.urlsafe_b64decode(parts[0] + ("=" * padding_len)).decode("utf-8")
except Exception:
fail("invalid protected token text")
return token + "|" + parts[1] + "|" + parts[2]
def main() -> None:
if len(sys.argv) < 3:
usage()
mode = sys.argv[1]
key_file = sys.argv[2]
if mode == "encipher":
now = 0
ttl = 30
input_text = ""
if len(sys.argv) >= 4:
input_text = sys.argv[3]
else:
fail("missing token")
if len(sys.argv) >= 5:
try:
ttl = int(sys.argv[4])
except ValueError:
fail("invalid ttl-seconds")
now = int(time.time())
public_key = load_public_key(key_file)
print(encipher(public_key, make_token_payload(input_text, now, ttl)))
return
if mode == "decipher":
input_text = ""
if len(sys.argv) >= 4:
input_text = sys.argv[3]
else:
input_text = sys.stdin.read()
private_key = load_private_key(key_file)
print(parse_token_payload(decipher(private_key, input_text)))
return
usage()
if __name__ == "__main__":
main()
+176
View File
@@ -0,0 +1,176 @@
package hodu
import "crypto/aes"
import "crypto/cipher"
import "crypto/rand"
import "crypto/rsa"
import "crypto/sha256"
import "encoding/base64"
import "fmt"
import "strconv"
import "strings"
import "time"
// currently, it supports rsa-aes-128-gcm only.
type RSAAES struct {
key *rsa.PrivateKey
}
type RSAAESToken struct {
Token string
IssuedAt time.Time
ExpiresAt time.Time
}
func encode_url_base64(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func decode_url_base64(text string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(text)
}
func NewRSAAES(key *rsa.PrivateKey) *RSAAES {
return &RSAAES{key: key}
}
func (e *RSAAES) Encipher(data []byte) (string, error) {
var aes_key []byte
var block cipher.Block
var gcm cipher.AEAD
var nonce []byte
var ciphertext []byte
var encrypted_key []byte
var err error
if e.key == nil {
return "", fmt.Errorf("missing rsa key")
}
aes_key = make([]byte, 32)
_, err = rand.Read(aes_key)
if err != nil { return "", err }
block, err = aes.NewCipher(aes_key)
if err != nil { return "", err }
gcm, err = cipher.NewGCM(block)
if err != nil { return "", err }
nonce = make([]byte, gcm.NonceSize())
_, err = rand.Read(nonce)
if err != nil { return "", err }
ciphertext = gcm.Seal(nil, nonce, data, nil)
encrypted_key, err = rsa.EncryptOAEP(sha256.New(), rand.Reader, &e.key.PublicKey, aes_key, nil)
if err != nil { return "", err }
return encode_url_base64(encrypted_key) +
"." + encode_url_base64(nonce) +
"." + encode_url_base64(ciphertext), nil
}
func (e *RSAAES) Decipher(doc string) ([]byte, error) {
var parts []string
var encrypted_key []byte
var nonce []byte
var ciphertext []byte
var aes_key []byte
var block cipher.Block
var gcm cipher.AEAD
var plaintext []byte
var err error
if e.key == nil {
return nil, fmt.Errorf("missing rsa key")
}
parts = strings.Split(doc, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid serialized token document")
}
encrypted_key, err = decode_url_base64(parts[0])
if err != nil { return nil, fmt.Errorf("invalid encrypted key - %s", err.Error()) }
nonce, err = decode_url_base64(parts[1])
if err != nil { return nil, fmt.Errorf("invalid nonce - %s", err.Error()) }
ciphertext, err = decode_url_base64(parts[2])
if err != nil { return nil, fmt.Errorf("invalid ciphertext - %s", err.Error()) }
aes_key, err = rsa.DecryptOAEP(sha256.New(), rand.Reader, e.key, encrypted_key, nil)
if err != nil { return nil, fmt.Errorf("failed to decrypt aes key - %s", err.Error()) }
block, err = aes.NewCipher(aes_key)
if err != nil { return nil, fmt.Errorf("invalid aes key - %s", err.Error()) }
gcm, err = cipher.NewGCM(block)
if err != nil { return nil, err }
if len(nonce) != gcm.NonceSize() {
return nil, fmt.Errorf("invalid nonce size %d", len(nonce))
}
plaintext, err = gcm.Open(nil, nonce, ciphertext, nil)
if err != nil { return nil, fmt.Errorf("failed to decrypt ciphertext - %s", err.Error()) }
return plaintext, nil
}
func (e *RSAAES) EncipherToken(token string, issued_at time.Time, expires_at time.Time) (string, error) {
var plain string
plain = base64.RawURLEncoding.EncodeToString([]byte(token)) +
"|" + strconv.FormatInt(issued_at.Unix(), 10) +
"|" + strconv.FormatInt(expires_at.Unix(), 10)
return e.Encipher([]byte(plain))
}
func (e *RSAAES) DecipherToken(doc string, now time.Time) (*RSAAESToken, error) {
var data []byte
var parts []string
var token_data []byte
var issued_at_n int64
var expires_at_n int64
var token RSAAESToken
var err error
data, err = e.Decipher(doc)
if err != nil { return nil, err }
parts = strings.SplitN(string(data), "|", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid protected token payload")
}
token_data, err = base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid protected token text - %s", err.Error())
}
issued_at_n, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid protected token issued-at - %s", err.Error())
}
expires_at_n, err = strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid protected token expiry - %s", err.Error())
}
token.Token = string(token_data)
token.IssuedAt = time.Unix(issued_at_n, 0)
token.ExpiresAt = time.Unix(expires_at_n, 0)
const time_format string = "2006-01-02 15:04:05 -0700"
if now.Before(token.IssuedAt) {
return nil, fmt.Errorf("protected token not valid until %s", token.IssuedAt.Format(time_format))
}
if !now.Before(token.ExpiresAt) {
return nil, fmt.Errorf("protected token expired at %s", token.ExpiresAt.Format(time_format))
}
return &token, nil
}
+94
View File
@@ -0,0 +1,94 @@
package hodu
import "bytes"
import "crypto/rand"
import "crypto/rsa"
import "testing"
import "time"
func TestRSAAESRoundTrip(t *testing.T) {
var key *rsa.PrivateKey
var codec *RSAAES
var input []byte
var doc string
var output []byte
var err error
key, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate rsa key - %s", err.Error())
}
codec = NewRSAAES(key)
input = []byte("client-token-123")
doc, err = codec.Encipher(input)
if err != nil {
t.Fatalf("failed to encipher - %s", err.Error())
}
output, err = codec.Decipher(doc)
if err != nil {
t.Fatalf("failed to decipher - %s", err.Error())
}
if !bytes.Equal(output, input) {
t.Fatalf("unexpected plaintext %q", string(output))
}
}
func TestRSAAESInvalidDocument(t *testing.T) {
var key *rsa.PrivateKey
var codec *RSAAES
var err error
key, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate rsa key - %s", err.Error())
}
codec = NewRSAAES(key)
_, err = codec.Decipher("broken")
if err == nil {
t.Fatal("expected decipher failure for invalid document")
}
}
func TestRSAAESTokenRoundTrip(t *testing.T) {
var key *rsa.PrivateKey
var codec *RSAAES
var issued_at time.Time
var expires_at time.Time
var doc string
var tok *RSAAESToken
var err error
key, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate rsa key - %s", err.Error())
}
codec = NewRSAAES(key)
issued_at = time.Unix(1700000000, 0)
expires_at = issued_at.Add(30 * time.Second)
doc, err = codec.EncipherToken("client-token-xyz", issued_at, expires_at)
if err != nil {
t.Fatalf("failed to encipher token - %s", err.Error())
}
tok, err = codec.DecipherToken(doc, issued_at.Add(5 * time.Second))
if err != nil {
t.Fatalf("failed to decipher token - %s", err.Error())
}
if tok.Token != "client-token-xyz" {
t.Fatalf("unexpected token %q", tok.Token)
}
if !tok.IssuedAt.Equal(issued_at) {
t.Fatalf("unexpected issued-at %v", tok.IssuedAt)
}
if !tok.ExpiresAt.Equal(expires_at) {
t.Fatalf("unexpected expires-at %v", tok.ExpiresAt)
}
}
+9 -9
View File
@@ -17,7 +17,7 @@ type ServerTokenClaim struct {
IssuedAt int64 `json:"iat"`
}
type json_out_token struct {
type json_out_auth_token struct {
AccessToken string `json:"access-token"`
RefreshToken string `json:"refresh-token,omitempty"`
}
@@ -164,7 +164,7 @@ func (ctl *ServerCtl) Cors(req *http.Request) bool {
func (ctl *ServerCtl) Authenticate(req *http.Request) (int, string) {
if ctl.NoAuth || ctl.S.Cfg.CtlAuth == nil { return http.StatusOK, "" }
return ctl.S.Cfg.CtlAuth.Authenticate(req)
return ctl.S.Cfg.CtlAuth.Authenticate(req, "")
}
// ------------------------------------
@@ -196,7 +196,7 @@ func (ctl *server_ctl_token) ServeHTTP(w http.ResponseWriter, req *http.Request)
claim.IssuedAt = now.Unix()
claim.ExpiresAt = now.Add(s.Cfg.CtlAuth.TokenTtl).Unix()
jwt = NewJWT(s.Cfg.CtlAuth.TokenRsaKey, &claim)
tok, err = jwt.SignRS512()
tok, err = jwt.SignRS256()
if err != nil {
status_code = WriteJsonRespHeader(w, http.StatusInternalServerError)
je.Encode(JsonErrmsg{Text: err.Error()})
@@ -204,7 +204,7 @@ func (ctl *server_ctl_token) ServeHTTP(w http.ResponseWriter, req *http.Request)
}
status_code = WriteJsonRespHeader(w, http.StatusOK)
err = je.Encode(json_out_token{ AccessToken: tok }) // TODO: refresh token
err = je.Encode(json_out_auth_token{ AccessToken: tok }) // TODO: refresh token
if err != nil { goto oops }
default:
@@ -956,12 +956,13 @@ func (ctl *server_ctl_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
// handle authentication using the first message.
// end this task if authentication fails.
if !ctl.NoAuth && s.Cfg.CtlAuth != nil {
/*
var req *http.Request
req = ws.Request()
if req.Header.Get("Authorization") == "" {
var token string
token = req.FormValue("token")
token = req.FormValue("access-token") // this is an authorization token
if token != "" {
// websocket doesn't actual have extra headers except a few fixed
// ones. add "Authorization" header from the query paramerer and
@@ -969,11 +970,10 @@ func (ctl *server_ctl_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
}
}
*/
status_code, _ = s.Cfg.CtlAuth.Authenticate(req)
if status_code != http.StatusOK {
goto done
}
status_code, _ = s.Cfg.CtlAuth.Authenticate(ws.Request(), "access-token")
if status_code != http.StatusOK { goto done }
}
sbsc, err = s.bulletin.Subscribe("")
+90 -5
View File
@@ -58,6 +58,19 @@ func (pty *server_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
s = pty.S
req = ws.Request()
// handle authentication using the first message.
// end this task if authentication fails.
if s.Cfg.CtlAuth != nil {
var status_code int
var msg string
status_code, msg = s.Cfg.CtlAuth.Authenticate(req, "access-token")
if status_code != http.StatusOK {
ws.Close()
return status_code, fmt.Errorf("failed to authenticate - %s", msg)
}
}
conn_ready_chan = make(chan bool, 3)
wg.Add(1)
@@ -78,8 +91,8 @@ func (pty *server_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
var err error
poll_fds = []unix.PollFd{
unix.PollFd{Fd: int32(out.Fd()), Events: unix.POLLIN},
unix.PollFd{Fd: int32(pfd[0]), Events: unix.POLLIN},
unix.PollFd{Fd: int32(out.Fd()), Events: unix.POLLIN},
}
s.stats.pty_sessions.Add(1)
@@ -94,8 +107,8 @@ func (pty *server_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
continue
}
out_revents = poll_fds[0].Revents
sig_revents = poll_fds[1].Revents
sig_revents = poll_fds[0].Revents
out_revents = poll_fds[1].Revents
if (out_revents & unix.POLLIN) != 0 {
n, err = out.Read(buf[:])
@@ -114,7 +127,27 @@ func (pty *server_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
break
}
}
if (sig_revents & unix.POLLIN) != 0 {
// currently the code doesn't read from the signal fd as the only
// implemented signal is stop. for now it exits the loop.
/*
// read the signal fd
n, err = unix.Read(pfd[0], buf[0:1]) // consume the 1-byte header. signal string max 255 chars
if (err == nil && n > 0) {
err = read_fd_full(pfd[0], buf[0:n]) // consume n bytes
if (err == nil) {
// new pty session requested
if buf[0] == 'o' {
poll_fds = append(poll_fds,
} else if buf[0] == 'c' {
}
}
// treat read failure as stop notification
}
*/
s.log.Write(pty.Id, LOG_DEBUG, "[%s] Stop request noticed on pty signal pipe", req.RemoteAddr)
break
}
@@ -123,6 +156,7 @@ func (pty *server_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
s.log.Write(pty.Id, LOG_DEBUG, "[%s] Error detected on pty stdout", req.RemoteAddr)
break
}
if (sig_revents & (unix.POLLERR | unix.POLLHUP | unix.POLLNVAL)) != 0 {
s.log.Write(pty.Id, LOG_DEBUG, "[%s] EOF detected on pty signal pipe", req.RemoteAddr)
break
@@ -132,6 +166,7 @@ func (pty *server_pty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
break
}
}
s.stats.pty_sessions.Add(-1)
}
}()
@@ -205,6 +240,7 @@ ws_recv_loop:
tty = nil
}
if pfd[1] >= 0 {
// write to the signal fd
unix.Write(pfd[1], []byte{0})
}
break ws_recv_loop
@@ -288,16 +324,42 @@ func (rpty *server_rpty_ws) ServeWebsocket(ws *websocket.Conn) (int, error) {
s = rpty.S
req = ws.Request()
// handle authentication using the first message.
// end this task if authentication fails.
if s.Cfg.CtlAuth != nil {
var status_code int
var msg string
status_code, msg = s.Cfg.CtlAuth.Authenticate(req, "access-token")
if status_code != http.StatusOK {
ws.Close()
return status_code, fmt.Errorf("failed to authenticate - %s", msg)
}
}
token = req.FormValue("client-token")
if token == "" {
ws.Close()
return http.StatusBadRequest, fmt.Errorf("no client token specified")
}
if strings.EqualFold(s.Cfg.RptyClientTokenProtection, "rsa-aes-256-gcm") {
var rsa_aes *RSAAES
var rsa_token *RSAAESToken
rsa_aes = NewRSAAES(s.Cfg.RptyClientTokenRsaKey)
rsa_token, err = rsa_aes.DecipherToken(token, time.Now())
if err != nil {
s.log.Write(rpty.Id, LOG_WARN, "[%s] Failed to decrypt protected client token [%s] - %s", req.RemoteAddr, token, err.Error())
return http.StatusBadRequest, fmt.Errorf("invalid client token - %s", token)
}
token = rsa_token.Token
}
cts = s.FindServerConnByClientToken(token)
if cts == nil {
ws.Close()
return http.StatusBadRequest, fmt.Errorf("invalid client token - %s", token)
return http.StatusBadRequest, fmt.Errorf("client token not found - %s", token)
}
ws_recv_loop:
@@ -381,6 +443,27 @@ done:
}
// ------------------------------------------------------
//Originally, i used a plain xterm.html and use server_pty_xterm_file to serve
//other files that xterm.html loads. Now, i bundle everything into xterm.html
//so no other files need to be served
func (pty *server_pty_xterm_file) Authenticate(req *http.Request) (int, string) {
// The parent method ServerCtl.Authenticate() applies
// authentication to all resources. i want to exclude some files.
if pty.file == "xterm.html" {
// this is not a real api call. but at least for xterm.html,
// i don't bypass authentication and and in addition,
// i check the value of the "access-token" parameter for
// jwt authentication if it exists
return pty.S.Cfg.CtlAuth.Authenticate(req, "access-token")
}
// you can download other files without authentication
// even if authentication is enabled. but this part must not be reached
// because xterm.html is the only meanful resource as of now
return http.StatusOK, ""
}
func (pty *server_pty_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Request) (int, error) {
var s *Server
@@ -390,6 +473,7 @@ func (pty *server_pty_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
s = pty.S
switch pty.file {
/*
case "xterm.js":
status_code = WriteJsRespHeader(w, http.StatusOK)
w.Write(xterm_js)
@@ -402,10 +486,11 @@ func (pty *server_pty_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
case "xterm.css":
status_code = WriteCssRespHeader(w, http.StatusOK)
w.Write(xterm_css)
*/
case "xterm.html":
var tmpl *template.Template
tmpl = template.New("")
tmpl = template.New("").Delims("{{@@", "@@}}")
if s.xterm_html != "" {
_, err = tmpl.Parse(s.xterm_html)
} else {
+3 -1
View File
@@ -473,6 +473,7 @@ func (pxy *server_pxy_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
s = pxy.S
switch pxy.file {
/*
case "xterm.js":
status_code = WriteJsRespHeader(w, http.StatusOK)
w.Write(xterm_js)
@@ -485,6 +486,7 @@ func (pxy *server_pxy_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
case "xterm.css":
status_code = WriteCssRespHeader(w, http.StatusOK)
w.Write(xterm_css)
*/
case "xterm.html":
var tmpl *template.Template
var conn_id string
@@ -509,7 +511,7 @@ func (pxy *server_pxy_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.Req
goto oops
}
tmpl = template.New("")
tmpl = template.New("").Delims("{{@@", "@@}}")
if s.xterm_html != "" {
_, err = tmpl.Parse(s.xterm_html)
} else {
+23
View File
@@ -10,6 +10,7 @@ import "net/http"
import "strconv"
import "strings"
import "sync"
import "time"
type server_rpx struct {
S *Server
@@ -31,6 +32,9 @@ func (rpx *server_rpx) Authenticate(req *http.Request) (int, string) {
func (rpx *server_rpx) get_client_token(req *http.Request) string {
var val string
var rsa_aes *RSAAES
var token *RSAAESToken
var err error
// TODO: enhance this client token extraction logic with some expression language?
val = req.Header.Get(rpx.S.Cfg.RpxClientTokenAttrName)
@@ -40,6 +44,16 @@ func (rpx *server_rpx) get_client_token(req *http.Request) string {
val = get_regex_submatch(rpx.S.Cfg.RpxClientTokenRegex, val, rpx.S.Cfg.RpxClientTokenSubmatchIndex)
}
if strings.EqualFold(rpx.S.Cfg.RpxClientTokenProtection, "rsa-aes-256-gcm") {
rsa_aes = NewRSAAES(rpx.S.Cfg.RpxClientTokenRsaKey)
token, err = rsa_aes.DecipherToken(val, time.Now())
if err != nil {
rpx.S.log.Write(rpx.Id, LOG_WARN, "[%s] Failed to decrypt protected client token [%s] - %s", req.RemoteAddr, val, err.Error())
return ""
}
val = token.Token
}
return val
}
@@ -50,6 +64,7 @@ func (rpx* server_rpx) handle_header_data(rpx_id uint64, data []byte, w http.Res
var status_code int
var err error
// i don't want the sender hammer the server with very large header data
const rpx_header_line_max = 65535 // TODO: make this configurable
r = bufio.NewReader(bytes.NewReader(data))
@@ -231,10 +246,12 @@ func (rpx *server_rpx) ServeHTTP(w http.ResponseWriter, req *http.Request) (int,
var ws_upgrade bool
var buf [4096]byte
var wg sync.WaitGroup
var xinfo *HttpHandlerExtraInfo
var err error
s = rpx.S
client_token = rpx.get_client_token(req)
cts = s.FindServerConnByClientToken(client_token)
if cts == nil {
status_code = WriteEmptyRespHeader(w, http.StatusNotFound)
@@ -242,6 +259,12 @@ func (rpx *server_rpx) ServeHTTP(w http.ResponseWriter, req *http.Request) (int,
goto oops
}
xinfo = req.Context().Value(http_handler_extra_info_key).(*HttpHandlerExtraInfo)
if xinfo != nil {
// set more info for logging in the outer handler
xinfo.extra_id = client_token
}
srpx, err = rpx.alloc_server_rpx(cts, req)
if err != nil {
status_code = WriteEmptyRespHeader(w, http.StatusServiceUnavailable)
+24 -3
View File
@@ -2,6 +2,7 @@ package hodu
import "container/list"
import "context"
import "crypto/rsa"
import "crypto/tls"
import "errors"
import "fmt"
@@ -79,9 +80,14 @@ type ServerConfig struct {
CtlAuth *HttpAuthConfig
CtlCors bool
RptyClientTokenProtection string
RptyClientTokenRsaKey *rsa.PrivateKey
RpxAddrs []string
RpxTls *tls.Config
RpxClientTokenAttrName string
RpxClientTokenProtection string
RpxClientTokenRsaKey *rsa.PrivateKey
RpxClientTokenRegex *regexp.Regexp
RpxClientTokenSubmatchIndex int
@@ -1403,6 +1409,8 @@ func (s *Server) WrapHttpHandler(handler ServerHttpHandler) http.Handler {
var err error
var start_time time.Time
var time_taken time.Duration
var newctx context.Context
var xinfo HttpHandlerExtraInfo
// this deferred function is to overcome the recovering implemenation
// from panic done in go's http server. in that implemenation, panic
@@ -1416,6 +1424,9 @@ func (s *Server) WrapHttpHandler(handler ServerHttpHandler) http.Handler {
start_time = time.Now()
newctx = context.WithValue(req.Context(), http_handler_extra_info_key, &xinfo)
req = req.WithContext(newctx)
if handler.Cors(req) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "*")
@@ -1426,6 +1437,7 @@ func (s *Server) WrapHttpHandler(handler ServerHttpHandler) http.Handler {
} else {
var realm string
// authorization applies to a controll with proper Authenticate method override.
status_code, realm = handler.Authenticate(req)
if status_code == http.StatusUnauthorized {
if realm != "" {
@@ -1441,10 +1453,12 @@ func (s *Server) WrapHttpHandler(handler ServerHttpHandler) http.Handler {
time_taken = time.Since(start_time) // time.Now().Sub(start_time)
if status_code > 0 {
var id string = handler.Identity()
if xinfo.extra_id != "" { id = id + "/" + xinfo.extra_id }
if err != nil {
s.log.Write(handler.Identity(), LOG_INFO, "[%s] %s %s %d %.9f - %s", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds(), err.Error())
s.log.Write(id, LOG_INFO, "[%s] %s %s %d %.9f - %s", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds(), err.Error())
} else {
s.log.Write(handler.Identity(), LOG_INFO, "[%s] %s %s %d %.9f", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds())
s.log.Write(id, LOG_INFO, "[%s] %s %s %d %.9f", req.RemoteAddr, req.Method, req.RequestURI, status_code, time_taken.Seconds())
}
}
})
@@ -1464,7 +1478,9 @@ func (s *Server) SafeWrapWebsocketHandler(handler websocket.Handler) http.Handle
}
func (s *Server) WrapWebsocketHandler(handler ServerWebsocketHandler) websocket.Handler {
return websocket.Handler(func(ws *websocket.Conn) {
var f websocket.Handler
f = websocket.Handler(func(ws *websocket.Conn) {
var status_code int
var err error
var start_time time.Time
@@ -1486,6 +1502,7 @@ func (s *Server) WrapWebsocketHandler(handler ServerWebsocketHandler) websocket.
}
}
})
return f;
}
func NewServer(ctx context.Context, name string, logger Logger, cfg *ServerConfig) (*Server, error) {
@@ -1624,6 +1641,7 @@ func NewServer(ctx context.Context, name string, logger Logger, cfg *ServerConfi
s.ctl_mux.Handle("/_pty/ws",
s.SafeWrapWebsocketHandler(s.WrapWebsocketHandler(&server_pty_ws{S: &s, Id: HS_ID_CTL})))
/* Not needed any more as xterm.html bundles everything
s.ctl_mux.Handle("/_pty/xterm.js",
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "xterm.js"}))
s.ctl_mux.Handle("/_pty/xterm.js/",
@@ -1640,6 +1658,7 @@ func NewServer(ctx context.Context, name string, logger Logger, cfg *ServerConfi
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "xterm.css"}))
s.ctl_mux.Handle("/_pty/xterm.css/",
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "_forbidden"}))
*/
s.ctl_mux.Handle("/_pty/xterm.html",
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "xterm.html", mode: "pty"}))
s.ctl_mux.Handle("/_pty/xterm.html/",
@@ -1649,6 +1668,7 @@ func NewServer(ctx context.Context, name string, logger Logger, cfg *ServerConfi
s.ctl_mux.Handle("/_rpty/ws",
s.SafeWrapWebsocketHandler(s.WrapWebsocketHandler(&server_rpty_ws{S: &s, Id: HS_ID_CTL})))
/* Not needed any more as xterm.html bundles everything
s.ctl_mux.Handle("/_rpty/xterm.js",
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "xterm.js"}))
s.ctl_mux.Handle("/_rpty/xterm.js/",
@@ -1665,6 +1685,7 @@ func NewServer(ctx context.Context, name string, logger Logger, cfg *ServerConfi
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "xterm.css"}))
s.ctl_mux.Handle("/_rpty/xterm.css/",
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "_forbidden"}))
*/
s.ctl_mux.Handle("/_rpty/xterm.html",
s.WrapHttpHandler(&server_pty_xterm_file{ServerCtl: ServerCtl{S: &s, Id: HS_ID_CTL}, file: "xterm.html", mode: "rpty"}))
s.ctl_mux.Handle("/_rpty/xterm.html/",
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
import { resolve } from "node:path";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: ".vite-xterm",
emptyOutDir: true,
rollupOptions: {
input: resolve(".", "xterm-plain.html")
}
}
});
-8
View File
@@ -1,8 +0,0 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
File diff suppressed because one or more lines are too long
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal</title>
<style>
body {
margin: 0;
height: 100%;
}
#terminal-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
}
#terminal-info-container {
vertical-align: middle;
padding: 3px;
}
#terminal-target {
display: inline-block;
height: 100%;
vertical-align: middle;
}
#terminal-status {
display: inline-block;
font-weight: bold;
height: 100%;
vertical-align: middle;
}
#terminal-errmsg {
display: inline-block;
color: red;
height: 100%;
vertical-align: middle;
}
#terminal-control {
display: inline-block;
height: 100%;
vertical-align: middle;
}
#terminal-control button {
vertical-align: middle;
}
#terminal-view-container {
flex: 1;
width: 100%;
min-height: 0;
/*height: 100%;*/
}
#login-container {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99999;
}
#login-form-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: center;
}
#login-form-title {
line-height: 2em;
font-size: 2em;
font-weight: bold;
}
</style>
<script type="module" src="xterm-plain.js"></script>
</head>
<body data-xt-mode="{{@@ .Mode @@}}" data-conn-id="{{@@ .ConnId @@}}" data-route-id="{{@@ .RouteId @@}}">
<div id="login-container">
<div id="login-form-container">
<div id="login-form-title"></div>
<form id="login-form">
<div id="login-ssh-part" style="display: none;">
<label>
Username: <input type="text" id="username" disabled required />
</label>
<br /><br />
<label>
Password: <input type="password" id="password" disabled required />
</label>
</div>
<div id="login-pty-part" style="display: none;">
Click Connect below to start a new terminal session
</div>
<div id="login-submit-part" style="padding-top: 1em;">
<button type="submit" id="terminal-connect">Connect</button>
</div>
</form>
</div>
</div>
<div id="terminal-container">
<div id="terminal-info-container">
<div id="terminal-target"></div>
<div id="terminal-status"></div>
<div id="terminal-errmsg"></div>
<div id="terminal-control">
<button id="terminal-disconnect" type="button">Disconnect</button>
</div>
</div>
<div id="terminal-view-container"></div>
</div>
</body>
</html>
+233
View File
@@ -0,0 +1,233 @@
import '@xterm/xterm/css/xterm.css';
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
function utf8ToBase64(str) {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const binaryString = String.fromCharCode.apply(null, data);
return btoa(binaryString);
}
function base64ToBinary(b64) {
const binaryString = atob(b64);
const bytes = new Uint8Array(binaryString.length);
let i;
for (i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
window.onload = function(event) {
const xt_mode = document.body.dataset.xtMode || "";
const conn_id = document.body.dataset.connId || "";
const route_id = document.body.dataset.routeId || "";
const terminal_target = document.getElementById("terminal-target");
const terminal_status = document.getElementById("terminal-status");
const terminal_errmsg = document.getElementById("terminal-errmsg");
const terminal_view_container = document.getElementById("terminal-view-container");
const terminal_connect = document.getElementById("terminal-connect");
const terminal_disconnect = document.getElementById("terminal-disconnect");
const login_container = document.getElementById("login-container");
const login_form_title = document.getElementById("login-form-title");
const login_form = document.getElementById("login-form");
const login_ssh_part = document.getElementById("login-ssh-part");
const login_pty_part = document.getElementById("login-pty-part");
const username_field = document.getElementById("username");
const password_field = document.getElementById("password");
const qparams = new URLSearchParams(window.location.search);
const term = new Terminal({
lineHeight: 1.2,
fontFamily: "Ubuntu Mono, Consolas, SF Mono, courier-new, courier, monospace",
allowProposedApi: true
});
const fit_addon = new FitAddon();
const unicode11_addon = new Unicode11Addon();
const text_decoder = new TextDecoder();
let set_terminal_target;
let set_terminal_status;
let adjust_terminal_size_unconnected;
let fetch_session_info;
let toggle_login_form;
void event;
if (xt_mode == "ssh") {
login_ssh_part.style.display = "block";
username_field.disabled = false;
password_field.disabled = false;
login_pty_part.style.display = "none";
} else {
login_ssh_part.style.display = "none";
username_field.disabled = true;
password_field.disabled = true;
login_pty_part.style.display = "block";
}
term.loadAddon(fit_addon);
term.loadAddon(unicode11_addon);
term.unicode.activeVersion = "11";
term.open(terminal_view_container);
set_terminal_target = function(name) {
terminal_target.innerText = name;
login_form_title.innerText = name;
};
set_terminal_status = function(msg, errmsg) {
if (msg != null) terminal_status.innerText = msg;
if (errmsg != null) {
if (errmsg != "") {
const d = new Date();
terminal_errmsg.innerText = "[" + d.toLocaleString() + "] " + errmsg;
} else {
terminal_errmsg.innerText = errmsg;
}
}
};
adjust_terminal_size_unconnected = function() {
fit_addon.fit();
};
fetch_session_info = async function() {
let url = window.location.protocol + "//" + window.location.host;
let pathname = window.location.pathname;
pathname = pathname.substring(0, pathname.lastIndexOf("/"));
url += pathname + "/session-info";
try {
const resp = await fetch(url);
if (!resp.ok) {
if (xt_mode == "ssh")
throw new Error(`HTTP error in getting route(${conn_id},${route_id}) information - status ${resp.status}`);
else
throw new Error(`HTTP error in getting session information - status ${resp.status}`);
}
const route = await resp.json();
if ("client-peer-name" in route) {
set_terminal_target(route["client-peer-name"]);
document.title = route["client-peer-name"];
}
} catch (e) {
set_terminal_target("");
document.title = "";
set_terminal_status(null, e);
}
};
toggle_login_form = function(visible) {
if (visible && xt_mode == "ssh") fetch_session_info();
login_container.style.visibility = (visible ? "visible" : "hidden");
terminal_disconnect.style.visibility = (visible ? "hidden" : "visible");
if (visible) {
if (xt_mode == "ssh") username_field.focus();
else terminal_connect.focus();
} else {
term.focus();
}
};
toggle_login_form(true);
window.onresize = adjust_terminal_size_unconnected;
adjust_terminal_size_unconnected();
login_form.onsubmit = async function(event) {
let username = "";
let password = "";
const prefix = window.location.protocol === "https:" ? "wss://" : "ws://";
let pathname = window.location.pathname;
const xparams = new URLSearchParams();
let url;
let socket;
event.preventDefault();
toggle_login_form(false);
if (xt_mode == "ssh") {
username = username_field.value.trim();
password = password_field.value.trim();
}
pathname = pathname.substring(0, pathname.lastIndexOf("/"));
url = prefix + window.location.host + pathname + "/ws";
if (xt_mode == "rpty") {
const access_token = qparams.get("access-token");
const client_token = qparams.get("client-token");
if (access_token != null && access_token != "") xparams.set("access-token", access_token);
if (client_token != null && client_token != "") xparams.set("client-token", client_token);
if (xparams.size > 0) url += "?" + xparams.toString();
}
socket = new WebSocket(url);
socket.binaryType = "arraybuffer";
set_terminal_status("Connecting...", "");
const adjust_terminal_size_connected = function() {
fit_addon.fit();
if (socket.readyState == 1)
socket.send(JSON.stringify({ type: "size", data: [term.rows.toString(), term.cols.toString()] }));
};
socket.onopen = function() {
socket.send(JSON.stringify({ type: "open", data: [username, password] }));
};
socket.onmessage = function(event) {
try {
let event_text;
let parsed_msg;
event_text = (typeof event.data === "string") ? event.data : text_decoder.decode(new Uint8Array(event.data));
parsed_msg = JSON.parse(event_text);
if (parsed_msg.type == "iov") {
for (const data of parsed_msg.data) term.write(base64ToBinary(data));
} else if (parsed_msg.type == "status") {
if (parsed_msg.data.length >= 1) {
if (parsed_msg.data[0] == "opened") {
set_terminal_status("Connected", "");
adjust_terminal_size_connected();
term.clear();
} else if (parsed_msg.data[0] == "closed") {
}
}
} else if (parsed_msg.type == "error") {
set_terminal_status(null, parsed_msg.data.join(" "));
}
} catch (e) {
set_terminal_status(null, e);
}
};
socket.onerror = function(event) {
void event;
set_terminal_status("Disconnected", "");
toggle_login_form(true);
window.onresize = adjust_terminal_size_unconnected;
};
socket.onclose = function() {
set_terminal_status("Disconnected", null);
toggle_login_form(true);
window.onresize = adjust_terminal_size_unconnected;
};
term.onData(function(data) {
if (socket.readyState == 1)
socket.send(JSON.stringify({ type: "iov", data: [utf8ToBase64(data)] }));
});
window.onresize = adjust_terminal_size_connected;
terminal_disconnect.onclick = function(event) {
void event;
socket.send(JSON.stringify({ type: "close", data: [""] }));
};
};
};
-7
View File
@@ -1,7 +0,0 @@
/**
* Minified by jsDelivr using clean-css v5.3.2.
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
+41 -227
View File
File diff suppressed because one or more lines are too long
-8
View File
File diff suppressed because one or more lines are too long