Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
311 lines
12 KiB
Go
311 lines
12 KiB
Go
// Command oneos-local-fixture is a disposable loopback-only OneOS simulator.
|
|
// It is NOT a OneOS implementation or an upstream deployment artifact.
|
|
package main
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/subtle"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const clientID = "vehicle-platform-local"
|
|
const tenantID = "local-test"
|
|
const password = "LocalOnly123!"
|
|
|
|
type user struct {
|
|
ID, Username, Name, VIN string
|
|
Disabled, NoAccess bool
|
|
}
|
|
type session struct {
|
|
User string
|
|
ExpiresAt time.Time
|
|
}
|
|
type ticket struct {
|
|
Token string
|
|
ExpiresAt time.Time
|
|
}
|
|
type fixture struct {
|
|
mu sync.Mutex
|
|
key *rsa.PrivateKey
|
|
serviceToken, platformOrigin string
|
|
users map[string]*user
|
|
sessions map[string]session
|
|
tickets map[string]ticket
|
|
}
|
|
|
|
func randomToken() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
func response(w http.ResponseWriter, status int, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data})
|
|
}
|
|
func loopbackOrigin(value string) bool {
|
|
u, e := url.Parse(value)
|
|
if e != nil {
|
|
return false
|
|
}
|
|
ip := net.ParseIP(u.Hostname())
|
|
return u.Scheme == "http" && ip != nil && ip.IsLoopback() && u.User == nil && u.RawQuery == "" && u.Fragment == "" && u.Path == ""
|
|
}
|
|
|
|
func main() {
|
|
listen := flag.String("listen", "127.0.0.1:20410", "literal loopback listener")
|
|
origin := flag.String("platform-origin", "http://127.0.0.1:20400", "local platform origin")
|
|
configPath := flag.String("config-file", "", "required private JSON output for local launcher")
|
|
flag.Parse()
|
|
host, _, e := net.SplitHostPort(*listen)
|
|
if e != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() || !loopbackOrigin(*origin) || *configPath == "" {
|
|
log.Fatal("literal loopback endpoints and config-file required")
|
|
}
|
|
key, e := rsa.GenerateKey(rand.Reader, 2048)
|
|
if e != nil {
|
|
log.Fatal(e)
|
|
}
|
|
f := &fixture{key: key, serviceToken: randomToken(), platformOrigin: *origin, users: map[string]*user{"alpha": {ID: "101", Username: "alpha", Name: "本地甲组用户", VIN: "LB9A32A24R0LS1426"}, "beta": {ID: "102", Username: "beta", Name: "本地乙组用户", VIN: "LNXNEGRR7SR318212"}, "noaccess": {ID: "103", Username: "noaccess", Name: "本地未授权用户", NoAccess: true}}, sessions: map[string]session{}, tickets: map[string]ticket{}}
|
|
public, e := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
|
if e != nil {
|
|
log.Fatal(e)
|
|
}
|
|
data, _ := json.Marshal(map[string]string{"baseURL": "http://" + *listen, "publicOrigin": *origin, "issuer": "oneos-local-fixture", "tenantId": tenantID, "clientId": clientID, "serviceToken": f.serviceToken, "publicKey": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))})
|
|
file, e := os.OpenFile(*configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if e != nil {
|
|
log.Fatal("cannot create private local config")
|
|
}
|
|
_, e = file.Write(data)
|
|
file.Close()
|
|
if e != nil {
|
|
log.Fatal(e)
|
|
}
|
|
server := &http.Server{Addr: *listen, Handler: f, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
|
|
log.Printf("Local fixture only: http://%s (accounts alpha/beta/noaccess; no external clients)", *listen)
|
|
log.Fatal(server.ListenAndServe())
|
|
}
|
|
|
|
func (f *fixture) issue(name string) (string, session) {
|
|
s := session{User: name, ExpiresAt: time.Now().Add(20 * time.Minute)}
|
|
token := randomToken()
|
|
f.sessions[token] = s
|
|
return token, s
|
|
}
|
|
func (f *fixture) current(r *http.Request) (string, *user, session, bool) {
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
s, ok := f.sessions[token]
|
|
u := f.users[s.User]
|
|
return token, u, s, ok && u != nil && !u.Disabled && s.ExpiresAt.After(time.Now()) && r.Header.Get("ClientID") == clientID
|
|
}
|
|
|
|
func (f *fixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
if r.URL.Path == "/health" {
|
|
response(w, 200, map[string]bool{"localOnly": true})
|
|
return
|
|
}
|
|
if r.URL.Path == "/auth/code" && r.Method == "GET" {
|
|
response(w, 200, map[string]any{"captchaEnabled": false, "uuid": "", "img": ""})
|
|
return
|
|
}
|
|
if r.URL.Path == "/auth/login" && r.Method == "POST" {
|
|
input, err := f.decryptLogin(r)
|
|
if err != nil || input["clientId"] != clientID || input["tenantId"] != tenantID || input["grantType"] != "password" || input["password"] != password || f.users[input["username"]] == nil || f.users[input["username"]].Disabled {
|
|
response(w, 401, nil)
|
|
return
|
|
}
|
|
token, s := f.issue(input["username"])
|
|
response(w, 200, map[string]any{"access_token": token, "client_id": clientID, "expire_in": int(time.Until(s.ExpiresAt).Seconds())})
|
|
return
|
|
}
|
|
if r.URL.Path == "/auth/inner/v1/sso/vehicle-platform/token-exchange" && r.Method == "POST" {
|
|
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+f.serviceToken)) != 1 {
|
|
response(w, 401, nil)
|
|
return
|
|
}
|
|
var input struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil {
|
|
response(w, 400, nil)
|
|
return
|
|
}
|
|
t, ok := f.tickets[input.Code]
|
|
delete(f.tickets, input.Code)
|
|
s, live := f.sessions[t.Token]
|
|
u := f.users[s.User]
|
|
if !ok || !live || !t.ExpiresAt.After(time.Now()) || !s.ExpiresAt.After(time.Now()) || u == nil || u.Disabled {
|
|
response(w, 401, nil)
|
|
return
|
|
}
|
|
response(w, 200, map[string]any{"accessToken": t.Token, "tokenType": "Bearer", "clientId": clientID, "expiresAt": s.ExpiresAt})
|
|
return
|
|
}
|
|
if r.URL.Path == "/system/user/getInfo" || r.URL.Path == "/asset/vehicle-platform/scope" || r.URL.Path == "/auth/logout" {
|
|
token, u, _, ok := f.current(r)
|
|
if !ok {
|
|
response(w, 401, nil)
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/auth/logout":
|
|
if r.Method != "POST" {
|
|
response(w, 405, nil)
|
|
return
|
|
}
|
|
delete(f.sessions, token)
|
|
response(w, 200, map[string]bool{"loggedOut": true})
|
|
case "/system/user/getInfo":
|
|
permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:monitor:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"}
|
|
if u.NoAccess {
|
|
permissions = []string{}
|
|
}
|
|
response(w, 200, map[string]any{"user": map[string]any{"userId": u.ID, "tenantId": tenantID, "userName": u.Username, "nickName": u.Name, "deptId": u.ID, "deptName": "本地测试部门", "status": "0"}, "permissions": permissions, "roles": []string{"local-business-user"}})
|
|
case "/asset/vehicle-platform/scope":
|
|
vehicles := []map[string]any{}
|
|
if u.VIN != "" && !u.NoAccess {
|
|
vehicles = append(vehicles, map[string]any{"vin": u.VIN, "validFrom": "2026-01-01T00:00:00Z"})
|
|
}
|
|
response(w, 200, map[string]any{"userId": u.ID, "tenantId": tenantID, "permissionVersion": "fixture-v1", "expiresAt": time.Now().Add(30 * time.Second), "vehicles": vehicles})
|
|
}
|
|
return
|
|
}
|
|
if r.URL.Path == "/__local/control" && r.Method == "POST" {
|
|
if r.Header.Get("Authorization") != "Bearer "+f.serviceToken {
|
|
response(w, 401, nil)
|
|
return
|
|
}
|
|
var input struct{ Username, Action string }
|
|
if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || f.users[input.Username] == nil {
|
|
response(w, 400, nil)
|
|
return
|
|
}
|
|
u := f.users[input.Username]
|
|
switch input.Action {
|
|
case "disable":
|
|
u.Disabled = true
|
|
case "enable":
|
|
u.Disabled = false
|
|
case "revoke-vehicles":
|
|
u.VIN = ""
|
|
case "restore-vehicles":
|
|
if u.Username == "alpha" {
|
|
u.VIN = "LB9A32A24R0LS1426"
|
|
} else if u.Username == "beta" {
|
|
u.VIN = "LNXNEGRR7SR318212"
|
|
}
|
|
default:
|
|
response(w, 400, nil)
|
|
return
|
|
}
|
|
response(w, 200, map[string]bool{"localOnly": true})
|
|
return
|
|
}
|
|
if r.URL.Path == "/portal/login" && r.Method == "POST" {
|
|
if r.ParseForm() != nil || r.FormValue("password") != password || f.users[r.FormValue("username")] == nil {
|
|
http.Error(w, "本地测试账号或密码错误", 401)
|
|
return
|
|
}
|
|
token, _ := f.issue(r.FormValue("username"))
|
|
http.SetCookie(w, &http.Cookie{Name: "fixture_oneos", Value: token, HttpOnly: true, SameSite: http.SameSiteLaxMode, Path: "/"})
|
|
http.Redirect(w, r, "/", 303)
|
|
return
|
|
}
|
|
if r.URL.Path == "/portal/ticket" && r.Method == "POST" {
|
|
cookie, e := r.Cookie("fixture_oneos")
|
|
if e != nil {
|
|
http.Error(w, "请先登录本地模拟 OneOS", 401)
|
|
return
|
|
}
|
|
s, ok := f.sessions[cookie.Value]
|
|
if !ok || !s.ExpiresAt.After(time.Now()) {
|
|
http.Error(w, "本地会话已失效", 401)
|
|
return
|
|
}
|
|
code := randomToken()
|
|
f.tickets[code] = ticket{Token: cookie.Value, ExpiresAt: time.Now().Add(5 * time.Second)}
|
|
http.Redirect(w, r, f.platformOrigin+"/api/v2/auth/oneos/callback?code="+url.QueryEscape(code), 303)
|
|
return
|
|
}
|
|
if r.URL.Path == "/" && r.Method == "GET" {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
name := ""
|
|
if c, e := r.Cookie("fixture_oneos"); e == nil {
|
|
if s, ok := f.sessions[c.Value]; ok && s.ExpiresAt.After(time.Now()) {
|
|
name = s.User
|
|
}
|
|
}
|
|
_ = portal.Execute(w, map[string]string{"Name": name})
|
|
return
|
|
}
|
|
response(w, 404, nil)
|
|
}
|
|
|
|
var portal = template.Must(template.New("portal").Parse(`<!doctype html><html lang="zh"><meta charset="utf-8"><title>本地 OneOS 模拟器</title><style>body{font:16px system-ui;max-width:640px;margin:80px auto;background:#f4f6fa}main{background:white;padding:36px;border-radius:20px}label{display:block;margin:18px 0}input,button{padding:12px;font:inherit}button{cursor:pointer}small{color:#596579}</style><main><h1>本地 OneOS 模拟器</h1><p>仅回环地址 · 虚构账号 · 不连接线上</p>{{if .Name}}<p>当前 OneOS 用户:<strong>{{.Name}}</strong></p><form method="post" action="/portal/ticket"><button>使用一次性 Ticket 进入中台</button></form>{{end}}<form method="post" action="/portal/login"><label>账号 <input name="username" value="alpha" required></label><label>密码 <input name="password" type="password" value="LocalOnly123!" required></label><button>登录本地 OneOS</button></form><p><small>测试账号 alpha / beta / noaccess,密码 LocalOnly123!。前两个账号分别授权一辆不同车辆;第三个没有中台入口权限。</small></p></main></html>`))
|
|
|
|
func (f *fixture) decryptLogin(r *http.Request) (map[string]string, error) {
|
|
wrapper, e := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
encoded, e := rsa.DecryptPKCS1v15(rand.Reader, f.key, wrapper)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
key, e := base64.StdEncoding.DecodeString(string(encoded))
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
raw, e := io.ReadAll(io.LimitReader(r.Body, 8193))
|
|
if e != nil || len(raw) > 8192 {
|
|
return nil, fmt.Errorf("bad body")
|
|
}
|
|
cipher, e := base64.StdEncoding.DecodeString(string(raw))
|
|
if e != nil || len(cipher) == 0 || len(cipher)%aes.BlockSize != 0 {
|
|
return nil, fmt.Errorf("bad cipher")
|
|
}
|
|
block, e := aes.NewCipher(key)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
plain := make([]byte, len(cipher))
|
|
for i := 0; i < len(cipher); i += aes.BlockSize {
|
|
block.Decrypt(plain[i:i+aes.BlockSize], cipher[i:i+aes.BlockSize])
|
|
}
|
|
pad := int(plain[len(plain)-1])
|
|
if pad < 1 || pad > aes.BlockSize || pad > len(plain) {
|
|
return nil, fmt.Errorf("bad padding")
|
|
}
|
|
for _, b := range plain[len(plain)-pad:] {
|
|
if int(b) != pad {
|
|
return nil, fmt.Errorf("bad padding")
|
|
}
|
|
}
|
|
var input map[string]string
|
|
e = json.Unmarshal(plain[:len(plain)-pad], &input)
|
|
return input, e
|
|
}
|