Compare commits
	
		
			13 Commits
		
	
	
		
			v0.0.2
			...
			26ad821bc0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						26ad821bc0
	
				 | 
					
					
						|||
| 
						
						
							
						
						9f0ebe81e3
	
				 | 
					
					
						|||
| 
						
						
							
						
						67cd6cb3cf
	
				 | 
					
					
						|||
| 
						
						
							
						
						22fd87a07a
	
				 | 
					
					
						|||
| 
						
						
							
						
						1d825c97f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						044cd3082e
	
				 | 
					
					
						|||
| 
						
						
							
						
						e7c4dc58b7
	
				 | 
					
					
						|||
| 
						
						
							
						
						2722e85a6a
	
				 | 
					
					
						|||
| 
						
						
							
						
						dcec80c184
	
				 | 
					
					
						|||
| 
						
						
							
						
						016dfaf14d
	
				 | 
					
					
						|||
| 
						
						
							
						
						b8487b766b
	
				 | 
					
					
						|||
| 
						
						
							
						
						a537906a17
	
				 | 
					
					
						|||
| 
						
						
							
						
						81d81b8a03
	
				 | 
					
					
						
							
								
								
									
										68
									
								
								.gitea/workflows/build.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								.gitea/workflows/build.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
name: Build and Push Docker Image
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - dev
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build-and-push:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    env:
 | 
			
		||||
      CGO_ENABLED: 1
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout codebase
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0
 | 
			
		||||
      - name: Pre Setup NodeJS
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '18.x'
 | 
			
		||||
      - name: For act to work
 | 
			
		||||
        run: npm -g install yarn
 | 
			
		||||
      - name: Setup NodeJS
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '18.x'
 | 
			
		||||
          cache: 'yarn'
 | 
			
		||||
          cache-dependency-path: frontend
 | 
			
		||||
      - name: Build Frontend
 | 
			
		||||
        run: |
 | 
			
		||||
          make assets
 | 
			
		||||
          rm -rf /host/${{ gitea.workspace }} && mkdir -p /host/${{ gitea.workspace }}
 | 
			
		||||
          cp -a . /host/${{ gitea.workspace }}/
 | 
			
		||||
      - name: Build Yggdrasil Server
 | 
			
		||||
        uses: crazy-max/ghaction-xgo@v2
 | 
			
		||||
        with:
 | 
			
		||||
          xgo_version: latest
 | 
			
		||||
          go_version: 1.24
 | 
			
		||||
          dest: build
 | 
			
		||||
          prefix: yggdrasil
 | 
			
		||||
          targets: linux/amd64,linux/arm64
 | 
			
		||||
          v: true
 | 
			
		||||
          x: false
 | 
			
		||||
          race: false
 | 
			
		||||
          ldflags: -s -w -buildid=
 | 
			
		||||
          tags: nomsgpack sqlite mysql
 | 
			
		||||
          trimpath: true
 | 
			
		||||
      - name: Store Back Binaries
 | 
			
		||||
        run: |
 | 
			
		||||
          cp -a /host/${{ gitea.workspace }}/build/. build
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
        uses: docker/setup-qemu-action@v2
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v3
 | 
			
		||||
      - name: Login to Docker Registry
 | 
			
		||||
        uses: docker/login-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          registry: docker.sunxinao.cn
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: Build and push
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          context: .
 | 
			
		||||
          push: true
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
          tags: docker.sunxinao.cn/gardel/yggdrasil-go:latest
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@@ -52,7 +52,7 @@ jobs:
 | 
			
		||||
          SUFFIX="$(echo "$LINE" | grep -osE '\.\w+' || printf '')"
 | 
			
		||||
          cp -v "$LINE" "yggdrasil$SUFFIX"
 | 
			
		||||
          FILE="../$PREFIX.zip"
 | 
			
		||||
          zip -9v "$FILE" "yggdrasil$SUFFIX" *.ini assets
 | 
			
		||||
          zip -9rv "$FILE" "yggdrasil$SUFFIX" *.ini assets
 | 
			
		||||
          DGST="$FILE.dgst"
 | 
			
		||||
          openssl dgst -md5    "$FILE" | sed 's/([^)]*)//g' >>"$DGST"
 | 
			
		||||
          openssl dgst -sha1   "$FILE" | sed 's/([^)]*)//g' >>"$DGST"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,10 @@
 | 
			
		||||
FROM alpine:latest
 | 
			
		||||
FROM debian:12-slim
 | 
			
		||||
 | 
			
		||||
LABEL maintainer="Gardel <sunxinao@hotmail.com>"
 | 
			
		||||
LABEL "Description"="Go Yggdrasil Server"
 | 
			
		||||
 | 
			
		||||
RUN apt-get update && apt-get install -y ca-certificates
 | 
			
		||||
RUN update-ca-certificates
 | 
			
		||||
ARG TARGETOS
 | 
			
		||||
ARG TARGETARCH
 | 
			
		||||
RUN mkdir -p /app
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								README.md
									
									
									
									
									
								
							@@ -16,6 +16,12 @@
 | 
			
		||||
 | 
			
		||||
禁止其他违反 [EULA](https://account.mojang.com/documents/minecraft_eula) 的行为。
 | 
			
		||||
 | 
			
		||||
## 准备
 | 
			
		||||
 | 
			
		||||
+ 运行 Linux, Windows 或 MacOS 的主机
 | 
			
		||||
+ SMTP 服务器和账号用于发送密码找回邮件
 | 
			
		||||
+ MySQL 数据库(如果使用 sqlite 则不需要)
 | 
			
		||||
 | 
			
		||||
## 用法
 | 
			
		||||
 | 
			
		||||
下载或编译得到可执行文件并运行,将会自动生成所需的配置文件和数据库文件。
 | 
			
		||||
@@ -33,3 +39,11 @@
 | 
			
		||||
```shell
 | 
			
		||||
docker run -d --name yggdrasil-go -v $(pwd)/data:/app/data -p 8080:8080 gardel/yggdrasil-go:latest
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 计划
 | 
			
		||||
 | 
			
		||||
- [x] 支持密码重置
 | 
			
		||||
- [ ] 支持不同的数据库如 PostgreSQL 等
 | 
			
		||||
- [ ] 添加选项以支持完全离线模式(不检查 Mojang 接口)
 | 
			
		||||
- [ ] 添加选项以禁用邮箱验证
 | 
			
		||||
- [ ] 令牌持久化防止升级和重启时令牌生效
 | 
			
		||||
							
								
								
									
										1
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								main.go
									
									
									
									
									
								
							@@ -151,6 +151,7 @@ func main() {
 | 
			
		||||
	serverMeta.Meta.ImplementationVersion = meta.ImplementationVersion
 | 
			
		||||
	serverMeta.Meta.FeatureNoMojangNamespace = true
 | 
			
		||||
	serverMeta.Meta.FeatureEnableProfileKey = true
 | 
			
		||||
	serverMeta.Meta.FeatureEnableMojangAntiFeatures = true
 | 
			
		||||
	serverMeta.Meta.Links.Homepage = meta.SkinRootUrl + "/profile/"
 | 
			
		||||
	serverMeta.Meta.Links.Register = meta.SkinRootUrl + "/profile/"
 | 
			
		||||
	serverMeta.SkinDomains = meta.SkinDomains
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
/*
 | 
			
		||||
 * Copyright (C) 2022-2023. Gardel <sunxinao@hotmail.com> and contributors
 | 
			
		||||
 * Copyright (C) 2022-2025. Gardel <sunxinao@hotmail.com> and contributors
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU Affero General Public License as published by
 | 
			
		||||
@@ -37,6 +37,7 @@ type MetaInfo struct {
 | 
			
		||||
	FeatureLegacySkinApi            bool `json:"feature.legacy_skin_api,omitempty"`
 | 
			
		||||
	FeatureNoMojangNamespace        bool `json:"feature.no_mojang_namespace,omitempty"`
 | 
			
		||||
	FeatureEnableProfileKey         bool `json:"feature.enable_profile_key,omitempty"`
 | 
			
		||||
	FeatureEnableMojangAntiFeatures bool `json:"feature.enable_mojang_anti_features,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ServerMeta struct {
 | 
			
		||||
@@ -63,6 +64,7 @@ type HomeRouter interface {
 | 
			
		||||
type homeRouterImpl struct {
 | 
			
		||||
	serverMeta   ServerMeta
 | 
			
		||||
	myPubKey     KeyPair
 | 
			
		||||
	cachedPubKey *PublicKeys
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewHomeRouter(meta *ServerMeta) HomeRouter {
 | 
			
		||||
@@ -80,6 +82,10 @@ func (h *homeRouterImpl) Home(c *gin.Context) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *homeRouterImpl) PublicKeys(c *gin.Context) {
 | 
			
		||||
	if h.cachedPubKey != nil {
 | 
			
		||||
		c.JSON(http.StatusOK, h.cachedPubKey)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	publicKeys := PublicKeys{}
 | 
			
		||||
	err := util.GetObject("https://api.minecraftservices.com/publickeys", &publicKeys)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -89,4 +95,5 @@ func (h *homeRouterImpl) PublicKeys(c *gin.Context) {
 | 
			
		||||
	publicKeys.ProfilePropertyKeys = append(publicKeys.ProfilePropertyKeys, h.myPubKey)
 | 
			
		||||
	publicKeys.PlayerCertificateKeys = append(publicKeys.PlayerCertificateKeys, h.myPubKey)
 | 
			
		||||
	c.JSON(http.StatusOK, publicKeys)
 | 
			
		||||
	h.cachedPubKey = &publicKeys
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -79,6 +79,8 @@ func InitRouters(router *gin.Engine, db *gorm.DB, meta *ServerMeta, smtpCfg *ser
 | 
			
		||||
	}
 | 
			
		||||
	minecraftservices := router.Group("/minecraftservices")
 | 
			
		||||
	{
 | 
			
		||||
		minecraftservices.GET("/player/attributes", userRouter.PlayerAttributes)
 | 
			
		||||
		minecraftservices.GET("/privacy/blocklist", userRouter.PlayerBlockList)
 | 
			
		||||
		minecraftservices.POST("/player/certificates", userRouter.ProfileKey)
 | 
			
		||||
		minecraftservices.GET("/publickeys", homeRouter.PublicKeys)
 | 
			
		||||
		minecraftservices.GET("/minecraft/profile/lookup/:uuid", userRouter.UUIDToUUID)
 | 
			
		||||
 
 | 
			
		||||
@@ -38,6 +38,8 @@ type UserRouter interface {
 | 
			
		||||
	UUIDToUUID(c *gin.Context)
 | 
			
		||||
	QueryUUIDs(c *gin.Context)
 | 
			
		||||
	QueryProfile(c *gin.Context)
 | 
			
		||||
	PlayerAttributes(c *gin.Context)
 | 
			
		||||
	PlayerBlockList(c *gin.Context)
 | 
			
		||||
	ProfileKey(c *gin.Context)
 | 
			
		||||
	SendEmail(c *gin.Context)
 | 
			
		||||
	VerifyEmail(c *gin.Context)
 | 
			
		||||
@@ -299,6 +301,37 @@ func (u *userRouterImpl) QueryProfile(c *gin.Context) {
 | 
			
		||||
	c.JSON(http.StatusOK, response)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *userRouterImpl) PlayerAttributes(c *gin.Context) {
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{
 | 
			
		||||
		"privileges": gin.H{
 | 
			
		||||
			"onlineChat": gin.H{
 | 
			
		||||
				"enabled": true,
 | 
			
		||||
			},
 | 
			
		||||
			"multiplayerServer": gin.H{
 | 
			
		||||
				"enabled": true,
 | 
			
		||||
			},
 | 
			
		||||
			"multiplayerRealms": gin.H{
 | 
			
		||||
				"enabled": false,
 | 
			
		||||
			},
 | 
			
		||||
			"telemetry": gin.H{
 | 
			
		||||
				"enabled": false,
 | 
			
		||||
			},
 | 
			
		||||
			"optionalTelemetry": gin.H{
 | 
			
		||||
				"enabled": false,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		"profanityFilterPreferences": gin.H{
 | 
			
		||||
			"profanityFilterOn": false,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *userRouterImpl) PlayerBlockList(c *gin.Context) {
 | 
			
		||||
	c.JSON(http.StatusOK, gin.H{
 | 
			
		||||
		"blockedProfiles": []string{},
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *userRouterImpl) ProfileKey(c *gin.Context) {
 | 
			
		||||
	bearerToken := c.GetHeader("Authorization")
 | 
			
		||||
	if len(bearerToken) < 8 {
 | 
			
		||||
 
 | 
			
		||||
@@ -118,7 +118,7 @@ func (u *userServiceImpl) Register(username, password, profileName, ip string) (
 | 
			
		||||
	} else if _, err := mojangUsernameToUUID(profileName); err == nil {
 | 
			
		||||
		return nil, util.NewForbiddenOperationError("profileName duplicate")
 | 
			
		||||
	}
 | 
			
		||||
	matched, err := regexp.MatchString("^(\\w){3,}(\\.\\w+)*@(\\w){2,}((\\.\\w+)+)$", username)
 | 
			
		||||
	matched, err := regexp.MatchString("^\\w+@(\\w){2,}((\\.\\w+)+)$", username)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
@@ -394,12 +394,24 @@ func (u *userServiceImpl) QueryProfile(profileId uuid.UUID, unsigned bool, textu
 | 
			
		||||
 | 
			
		||||
func (u *userServiceImpl) ProfileKey(accessToken string) (resp *ProfileKeyResponse, err error) {
 | 
			
		||||
	token, ok := u.tokenService.GetToken(accessToken)
 | 
			
		||||
	var profileId uuid.UUID
 | 
			
		||||
	if ok && token.GetAvailableLevel() == model.Valid {
 | 
			
		||||
		profileId = token.SelectedProfile.Id
 | 
			
		||||
	} else {
 | 
			
		||||
		id, _, err := util.ParseOfficialToken(accessToken)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		profileId, err = util.ToUUID(id)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	resp = new(ProfileKeyResponse)
 | 
			
		||||
	now := time.Now().UTC()
 | 
			
		||||
	resp.RefreshedAfter = now
 | 
			
		||||
		resp.ExpiresAt = now.Add(time.Hour * 24 * 90)
 | 
			
		||||
		keyPair, err := u.getProfileKey(token.SelectedProfile.Id)
 | 
			
		||||
	resp.ExpiresAt = now.Add(90 * 24 * time.Hour)
 | 
			
		||||
	keyPair, err := u.getProfileKey(profileId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
@@ -411,12 +423,6 @@ func (u *userServiceImpl) ProfileKey(accessToken string) (resp *ProfileKeyRespon
 | 
			
		||||
	}
 | 
			
		||||
	resp.PublicKeySignature = sign
 | 
			
		||||
	resp.PublicKeySignatureV2 = sign
 | 
			
		||||
	} else {
 | 
			
		||||
		err = util.PostForString("https://api.minecraftservices.com/player/certificates", accessToken, []byte(""), resp)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return resp, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										75
									
								
								util/token_utils.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								util/token_utils.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
/*
 | 
			
		||||
 * Copyright (C) 2025. Gardel <sunxinao@hotmail.com> and contributors
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU Affero General Public License as published by
 | 
			
		||||
 * the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
 * (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU Affero General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU Affero General Public License
 | 
			
		||||
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package util
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/base64"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type officialTokenPayload struct {
 | 
			
		||||
	Xuid     string        `json:"xuid"`
 | 
			
		||||
	Agg      string        `json:"agg"`
 | 
			
		||||
	Sub      string        `json:"sub"`
 | 
			
		||||
	Auth     string        `json:"auth"`
 | 
			
		||||
	Ns       string        `json:"ns"`
 | 
			
		||||
	Roles    []interface{} `json:"roles"`
 | 
			
		||||
	Iss      string        `json:"iss"`
 | 
			
		||||
	Flags    []string      `json:"flags"`
 | 
			
		||||
	Profiles struct {
 | 
			
		||||
		Mc string `json:"mc"`
 | 
			
		||||
	} `json:"profiles"`
 | 
			
		||||
	Platform string `json:"platform"`
 | 
			
		||||
	Pfd      []struct {
 | 
			
		||||
		Type string `json:"type"`
 | 
			
		||||
		Id   string `json:"id"`
 | 
			
		||||
		Name string `json:"name"`
 | 
			
		||||
	} `json:"pfd"`
 | 
			
		||||
	Nbf int `json:"nbf"`
 | 
			
		||||
	Exp int `json:"exp"`
 | 
			
		||||
	Iat int `json:"iat"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ParseOfficialToken(token string) (id, name string, err error) {
 | 
			
		||||
	firstDot := strings.IndexRune(token, '.')
 | 
			
		||||
	if firstDot == -1 {
 | 
			
		||||
		return id, name, errors.New("invalid token")
 | 
			
		||||
	}
 | 
			
		||||
	secondDot := 1 + firstDot + strings.IndexRune(token[firstDot+1:], '.')
 | 
			
		||||
	if secondDot == -1 {
 | 
			
		||||
		return id, name, errors.New("invalid token")
 | 
			
		||||
	}
 | 
			
		||||
	jsonBase64 := token[firstDot+1 : secondDot]
 | 
			
		||||
	jsonDecoded, err := base64.RawURLEncoding.DecodeString(jsonBase64)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return id, name, err
 | 
			
		||||
	}
 | 
			
		||||
	payload := officialTokenPayload{}
 | 
			
		||||
	err = json.Unmarshal(jsonDecoded, &payload)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return id, name, err
 | 
			
		||||
	}
 | 
			
		||||
	if payload.Pfd == nil || len(payload.Pfd) == 0 {
 | 
			
		||||
		return id, name, errors.New("invalid token")
 | 
			
		||||
	}
 | 
			
		||||
	id = payload.Pfd[0].Id
 | 
			
		||||
	name = payload.Pfd[0].Name
 | 
			
		||||
	return id, name, nil
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user